{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "word-pullback",
  "title": "Word Pullback",
  "description": "The camera pulls back from a big word to reveal \"The cinematic toolkit for every launch\".",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "registryDependencies": ["@remocn/gsh"],
  "files": [
    {
      "path": "registry/remocn/word-pullback/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadSerif } from \"@remotion/google-fonts/InstrumentSerif\";\nimport { loadFont as loadSans } from \"@remotion/google-fonts/InterTight\";\nimport { loadFont as loadManrope } from \"@remotion/google-fonts/Manrope\";\nimport {\n  type CSSProperties,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport type { GradientParams } from \"@/lib/remocn/gsh/gradient-engine\";\nimport { GshGround, gshParams } from \"@/lib/remocn/gsh/gsh-ground\";\nimport type { GshPresetId } from \"@/lib/remocn/gsh/presets\";\n\nconst sans = loadSans(\"normal\", {\n  weights: [\"300\", \"400\"],\n  subsets: [\"latin\"],\n});\nconst serif = loadSerif(\"italic\", { weights: [\"400\"], subsets: [\"latin\"] });\nconst manrope = loadManrope(\"normal\", {\n  weights: [\"600\"],\n  subsets: [\"latin\"],\n});\n\nconst REMOCN_R_PATH =\n  \"M 0.01 0.81 C 0.01 1.73, 0.36 2.79, 1.09 4.13 C 4.91 11.04, 13.45 17.16, 21.7 18.9 C 22.94 19.16, 23.18 19.16, 51.39 19.27 C 76.07 19.36, 80.02 19.4, 81.32 19.57 C 89.89 20.69, 96.2 24.68, 99.38 31.01 C 103.19 38.56, 102.53 50.1, 97.91 57.07 C 94.66 61.96, 89.68 64.99, 83.26 66 C 82.81 66.07, 70.18 66.83, 55.2 67.69 C 24.82 69.43, 27.03 69.23, 24.18 70.4 C 19.9 72.15, 14.84 75.7, 10.65 79.89 C 4.86 85.68, 1.3 91.91, 0.25 98.13 C 0.12 98.85, 0.08 103.13, 0.04 116.66 L 0 134.26 9.5 134.26 L 19 134.26 19.05 119.41 C 19.1 103.27, 19.08 103.82, 19.82 101.04 C 21.79 93.65, 27.86 88.75, 36.45 87.63 C 37.23 87.53, 39.41 87.5, 43.57 87.53 C 50.12 87.59, 50.75 87.65, 53.22 88.45 C 56.61 89.56, 59.67 91.86, 62.02 95.07 C 62.52 95.76, 69.35 103.85, 77.2 113.07 C 85.04 122.28, 91.63 130.04, 91.85 130.32 C 92.07 130.59, 92.5 131.34, 92.82 131.97 C 93.52 133.35, 94.11 133.93, 95.06 134.13 C 95.5 134.23, 98.97 134.26, 106.36 134.23 L 117.01 134.19 100.82 113.07 C 91.91 101.45, 84.52 91.78, 84.39 91.57 C 83.36 89.89, 83.66 87.53, 85.09 86 C 85.79 85.25, 86.36 84.94, 88.07 84.38 C 96.18 81.72, 104.15 76.62, 109.97 70.36 C 120.59 58.93, 124.06 44.32, 119.44 30.43 C 114.59 15.81, 101.93 4.02, 87.64 0.83 C 83.93 0.01, 88.09 0.08, 41.85 0.04 L 0.01 0 0.01 0.81 Z\";\n\nfunction RemocnLogo({ fontSize, color }: { fontSize: number; color: string }) {\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        alignItems: \"baseline\",\n        color,\n        fontFamily: manrope.fontFamily,\n        fontWeight: 600,\n        fontSize,\n        letterSpacing: \"-0.025em\",\n        lineHeight: 1,\n      }}\n    >\n      <svg\n        viewBox=\"0 0 124.06 134.26\"\n        style={{ height: \"0.76em\", width: \"auto\" }}\n        aria-hidden\n      >\n        <path fill=\"currentColor\" d={REMOCN_R_PATH} />\n      </svg>\n      <span style={{ marginLeft: \"0.04em\" }}>emocn</span>\n    </div>\n  );\n}\n\nexport interface WordPullbackProps {\n  before?: string;\n  accentWord?: string;\n  after?: string;\n  accent?: string;\n  ink?: string;\n  background?: string;\n  theme?: \"dark\" | \"light\";\n  ground?: GshPresetId;\n  groundParams?: Partial<GradientParams>;\n}\n\nconst LIGHT_GROUND: Partial<GradientParams> = {\n  kind: \"mist\",\n  background: \"#f4efe8\",\n  colors: [\"#ffe2cc\", \"#f7ad82\", \"#eec2cf\", \"#f4efe8\"],\n  speed: 0.8,\n  loopSeconds: 14,\n  scale: 0.85,\n  warp: 0.4,\n  blur: 0.7,\n  grain: 0.22,\n  seed: 12,\n};\n\nconst THEMES = {\n  dark: {\n    ink: \"#f6efe8\",\n    accent: \"#ffc79a\",\n    logo: \"#f6efe8\",\n    ground: \"hearth\" as GshPresetId | undefined,\n    groundParams: undefined as Partial<GradientParams> | undefined,\n    groundOffset: 5,\n    veil: \"6,3,3\",\n    veilFrom: 0.72,\n    veilTo: 0.12,\n  },\n  light: {\n    ink: \"#1b1512\",\n    accent: \"#b3441c\",\n    logo: \"#1b1512\",\n    ground: undefined as GshPresetId | undefined,\n    groundParams: LIGHT_GROUND as Partial<GradientParams> | undefined,\n    groundOffset: 3,\n    veil: \"244,239,232\",\n    veilFrom: 0.75,\n    veilTo: 0.05,\n  },\n};\n\nconst BEAT = {\n  typeEnd: 27,\n  pull1: 30,\n  pull1Len: 18,\n  line2: 45,\n  wordStep: 3,\n  rise: 12,\n  pull2: 72,\n  pull2Len: 15,\n  logo: 78,\n  flood: 72,\n  floodLen: 24,\n};\n\nconst TRACK = -0.03;\nconst SERIF_SCALE = 1.18;\nconst FONT_PX = 200;\nconst RISE = Easing.bezier(0.22, 1, 0.36, 1);\nconst CAMERA = Easing.bezier(0.87, 0, 0.13, 1);\nconst TYPE = Easing.bezier(0.65, 0, 0.35, 1);\nconst FLOOD = Easing.bezier(0.32, 0.72, 0, 1);\nconst CHIP = { mass: 0.5, damping: 12, stiffness: 200 };\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface Measure {\n  sans: (s: string) => number;\n  serif: (s: string) => number;\n}\n\nfunction buildMeasure(_version: number): Measure {\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  const cache: Record<string, number> = {};\n  const get = (font: string, track: number, s: string) => {\n    const key = `${font}|${s}`;\n    if (cache[key] === undefined) {\n      if (!ctx) return s.length * 0.5;\n      ctx.font = font;\n      cache[key] =\n        ctx.measureText(s).width / FONT_PX + track * Array.from(s).length;\n    }\n    return cache[key];\n  };\n  return {\n    sans: (s) => get(`300 ${FONT_PX}px \"${sans.fontFamily}\"`, TRACK, s),\n    serif: (s) =>\n      get(`italic 400 ${FONT_PX}px \"${serif.fontFamily}\"`, -0.01, s) *\n      SERIF_SCALE,\n  };\n}\n\nfunction useMeasure() {\n  const [version, setVersion] = useState(0);\n  const [handle] = useState(() => delayRender(\"word-pullback: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    const bump = () => {\n      if (live) setVersion((v) => v + 1);\n    };\n    document.fonts.addEventListener(\"loadingdone\", bump);\n    Promise.all([\n      sans.waitUntilDone(),\n      serif.waitUntilDone(),\n      manrope.waitUntilDone(),\n      document.fonts.load(`300 100px \"${sans.fontFamily}\"`),\n      document.fonts.load(`italic 400 100px \"${serif.fontFamily}\"`),\n      document.fonts.load(`600 100px \"${manrope.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        bump();\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n      document.fonts.removeEventListener(\"loadingdone\", bump);\n    };\n  }, [handle]);\n  return useMemo(() => buildMeasure(version), [version]);\n}\n\ninterface Token {\n  text: string;\n  serif: boolean;\n  w: number;\n}\n\nexport function WordPullback({\n  before = \"The\",\n  accentWord = \"cinematic\",\n  after = \"toolkit for every launch\",\n  accent,\n  ink,\n  background,\n  theme = \"dark\",\n  ground,\n  groundParams,\n}: WordPullbackProps) {\n  const frame = useCurrentFrame();\n  const { width: W, height: H, fps, durationInFrames } = useVideoConfig();\n  const c = THEMES[theme];\n  const inkC = ink ?? c.ink;\n  const hi = accent ?? c.accent;\n  const preset = ground ?? (groundParams ? undefined : c.ground);\n  const gParams = groundParams ?? (ground ? undefined : c.groundParams);\n  const bg = background ?? gshParams(preset, gParams).background;\n  const m = useMeasure();\n  const portrait = H > W * 1.1;\n\n  const flood = interpolate(\n    frame,\n    [BEAT.flood, BEAT.flood + BEAT.floodLen],\n    [0, 1],\n    { ...CLAMP, easing: FLOOD },\n  );\n  const veil = c.veilFrom + (c.veilTo - c.veilFrom) * flood;\n\n  let content: React.ReactNode = <div />;\n  if (m) {\n    const cx = W / 2;\n    const cy = H / 2;\n    const marginX = W * (portrait ? 0.08 : 0.1);\n    const room = W - marginX * 2;\n    const beforeWords = before.split(\" \").filter(Boolean);\n    const tokens: Token[] = [\n      ...beforeWords.map((s) => ({ text: s, serif: false, w: m.sans(s) })),\n      { text: accentWord, serif: true, w: m.serif(accentWord) },\n      ...after\n        .split(\" \")\n        .filter(Boolean)\n        .map((s) => ({ text: s, serif: false, w: m.sans(s) })),\n    ];\n    const accentIdx = beforeWords.length;\n    const sp = m.sans(\" \");\n    const totalEm =\n      tokens.reduce((a, t) => a + t.w, 0) + sp * (tokens.length - 1);\n    const longest = Math.max(...tokens.map((t) => t.w));\n\n    const k = Math.min(\n      tokens.length,\n      portrait\n        ? Math.max(2, Math.ceil(totalEm / 5.2))\n        : totalEm > 16\n          ? 3\n          : totalEm > 8\n            ? 2\n            : 1,\n    );\n    const widthOf = (a: number, b: number) =>\n      tokens.slice(a, b).reduce((acc, t) => acc + t.w, 0) + sp * (b - a - 1);\n    const best: number[][] = [];\n    const cut: number[][] = [];\n    for (let j = 0; j <= k; j++) {\n      best.push(new Array(tokens.length + 1).fill(Number.POSITIVE_INFINITY));\n      cut.push(new Array(tokens.length + 1).fill(0));\n    }\n    best[0][0] = 0;\n    for (let j = 1; j <= k; j++) {\n      for (let b = 1; b <= tokens.length; b++) {\n        for (let a = j - 1; a < b; a++) {\n          const v = Math.max(best[j - 1][a], widthOf(a, b));\n          if (v < best[j][b]) {\n            best[j][b] = v;\n            cut[j][b] = a;\n          }\n        }\n      }\n    }\n    const lines: number[][] = [];\n    let end = tokens.length;\n    for (let j = k; j >= 1; j--) {\n      const a = cut[j][end];\n      lines.unshift(Array.from({ length: end - a }, (_, q) => a + q));\n      end = a;\n    }\n    const maxLineEm = best[k][tokens.length];\n    const T = portrait\n      ? Math.min(W * 0.16, room / Math.max(maxLineEm, longest))\n      : Math.min(H * 0.17, room / Math.max(maxLineEm, longest));\n\n    const lineH = T * 1.12;\n    const logoSize = T * 0.42;\n    const logoGap = T * 0.5;\n    const blockH = logoSize + logoGap + lines.length * lineH;\n    const top = cy - blockH / 2;\n    const lineY = (li: number) =>\n      top + logoSize + logoGap + li * lineH + lineH / 2;\n    const lineWidth = (ln: number[]) =>\n      ln.reduce((a, i) => a + tokens[i].w, 0) * T + sp * T * (ln.length - 1);\n\n    let accentX = cx;\n    let accentY = cy;\n    let accentLine = 0;\n    const placed: { i: number; x: number; y: number; li: number }[] = [];\n    lines.forEach((ln, li) => {\n      let x = cx - lineWidth(ln) / 2;\n      ln.forEach((i) => {\n        placed.push({ i, x, y: lineY(li), li });\n        if (i === accentIdx) {\n          accentX = x + (tokens[i].w * T) / 2;\n          accentY = lineY(li);\n          accentLine = li;\n        }\n        x += tokens[i].w * T + sp * T;\n      });\n    });\n\n    const accentPx = tokens[accentIdx].w * T;\n    const z0 = (W * (portrait ? 1.25 : 1.15)) / accentPx;\n    const maxLinePx = Math.max(...lines.map(lineWidth));\n    const z1 = Math.max(\n      1.12,\n      Math.min(Math.sqrt(z0) * 1.05, (W * 0.94) / maxLinePx),\n    );\n\n    const typeEnd = Math.max(\n      BEAT.typeEnd - 6,\n      Math.min(BEAT.typeEnd + 6, accentWord.length * 3),\n    );\n    const typed = interpolate(frame, [-9, typeEnd], [0, 1], {\n      ...CLAMP,\n      easing: TYPE,\n    });\n    const typedN = Math.max(1, Math.ceil(typed * accentWord.length));\n    const typedPx = m.serif(accentWord.slice(0, typedN)) * T;\n    const accentLeft = accentX - accentPx / 2;\n\n    const cam = (f: number) => {\n      const p1 = interpolate(\n        f,\n        [BEAT.pull1, BEAT.pull1 + BEAT.pull1Len],\n        [0, 1],\n        { ...CLAMP, easing: CAMERA },\n      );\n      const p2 = interpolate(\n        f,\n        [BEAT.pull2, BEAT.pull2 + BEAT.pull2Len],\n        [0, 1],\n        { ...CLAMP, easing: CAMERA },\n      );\n      const dolly = interpolate(f, [0, BEAT.pull1 + 6], [0, 1], CLAMP);\n      const tP = interpolate(f, [-9, typeEnd], [0, 1], {\n        ...CLAMP,\n        easing: TYPE,\n      });\n      const lz0 = Math.log(z0 * (1 - 0.08 * dolly));\n      const lz1 = Math.log(\n        z1 *\n          (1 -\n            0.04 *\n              interpolate(\n                f,\n                [BEAT.pull1 + BEAT.pull1Len, BEAT.pull2],\n                [0, 1],\n                CLAMP,\n              )),\n      );\n      const lnZ = lz0 + (lz1 - lz0) * p1 + (0 - lz1) * p2;\n      const f0x = accentLeft + accentPx * (0.18 + 0.5 * tP);\n      const f0y = accentY;\n      const f1x = cx;\n      const f1y = cy + (logoSize + logoGap) / 2;\n      const fx = f0x + (f1x - f0x) * p1 + (cx - f1x) * p2;\n      const fy = f0y + (f1y - f0y) * p1 + (cy - f1y) * p2;\n      return { lnZ, fx, fy };\n    };\n\n    const C = cam(frame);\n    const Cp = cam(frame - 1);\n    const vel = Math.abs(C.lnZ - Cp.lnZ);\n    const screenBlur = Math.min(8, vel * 30);\n    const push = interpolate(\n      frame,\n      [BEAT.pull2 + BEAT.pull2Len, durationInFrames],\n      [1, 1.03],\n      CLAMP,\n    );\n    const Z = Math.exp(C.lnZ) * push;\n    const depth = Math.max(0, Math.min(1, (Z - 1) / Math.max(0.001, z0 - 1)));\n\n    const reveal = Math.min(1, typedPx / Math.max(1, accentPx) + 0.015);\n\n    const tokenNodes = placed.map(({ i, x, y, li }) => {\n      const k = tokens[i];\n      const isAccent = i === accentIdx;\n      let enterAt = -99;\n      if (!isAccent && i > accentIdx) {\n        if (li === accentLine) {\n          enterAt = typeEnd - 3 + (i - accentIdx - 1) * BEAT.wordStep;\n        } else {\n          const firstOnLine = lines[li][0];\n          enterAt =\n            BEAT.line2 +\n            (li - accentLine - 1) * 6 +\n            (i - firstOnLine) * BEAT.wordStep;\n        }\n      }\n      const e = interpolate(frame, [enterAt, enterAt + BEAT.rise], [0, 1], {\n        ...CLAMP,\n        easing: RISE,\n      });\n      const dist = Math.abs(i - accentIdx);\n      const localBlur = isAccent\n        ? 0\n        : (depth * Math.min(2, dist) * 2.6) / Math.max(1, Z) +\n          (1 - e) * (5 / Math.max(1, Z));\n      const style: CSSProperties = {\n        position: \"absolute\",\n        left: x,\n        top: y,\n        transform: \"translateY(-50%)\",\n        whiteSpace: \"pre\",\n        lineHeight: 1,\n        color: isAccent ? hi : inkC,\n        fontFamily: isAccent ? serif.fontFamily : sans.fontFamily,\n        fontStyle: isAccent ? \"italic\" : \"normal\",\n        fontWeight: isAccent ? 400 : 300,\n        fontSize: isAccent ? T * SERIF_SCALE : T,\n        letterSpacing: isAccent ? \"-0.01em\" : `${TRACK}em`,\n        clipPath: isAccent\n          ? `inset(-30% ${((1 - reveal) * 100).toFixed(2)}% -30% -10%)`\n          : \"inset(-30% -10% -12% -10%)\",\n        marginTop: isAccent ? T * 0.03 : 0,\n        opacity: isAccent ? 1 : 0.62 + 0.38 * (1 - depth),\n        filter:\n          localBlur > 0.02 ? `blur(${localBlur.toFixed(3)}px)` : undefined,\n      };\n      return (\n        <div key={`t${i}`} style={style}>\n          <span\n            style={{\n              display: \"inline-block\",\n              transform: `translateY(${((1 - e) * 0.8).toFixed(4)}em)`,\n            }}\n          >\n            {k.text}\n          </span>\n        </div>\n      );\n    });\n\n    const logoIn = spring({ frame: frame - BEAT.logo, fps, config: CHIP });\n    const logoE = Math.min(1, Math.max(0, logoIn));\n    const caretOn =\n      frame < typeEnd + 4 && Math.floor(frame / 8) % 2 === 0 ? 1 : 0;\n    const caretX = accentLeft + typedPx + T * 0.04;\n\n    const layer = (scale: number, opacity: number, key: string) => (\n      <div\n        key={key}\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          width: W,\n          height: H,\n          transformOrigin: \"0 0\",\n          transform: `translate(${cx}px, ${cy}px) scale(${(Z * scale).toFixed(5)}) translate(${(-C.fx).toFixed(3)}px, ${(-C.fy).toFixed(3)}px)`,\n          opacity,\n        }}\n      >\n        {tokenNodes}\n        <div\n          style={{\n            position: \"absolute\",\n            left: caretX,\n            top: accentY - T * 0.48,\n            width: Math.max(1.5, T * 0.025),\n            height: T * 0.96,\n            background: hi,\n            opacity: caretOn * (frame < typeEnd + 4 ? 1 : 0),\n          }}\n        />\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            width: W,\n            top,\n            height: logoSize * 1.2,\n            display: \"flex\",\n            justifyContent: \"center\",\n          }}\n        >\n          <div\n            style={{\n              transform: `translateY(${((1 - logoIn) * logoSize * 0.9).toFixed(2)}px) scale(${(0.86 + 0.14 * logoIn).toFixed(4)})`,\n              opacity: logoE,\n              filter:\n                logoE < 1\n                  ? `blur(${((1 - logoE) * 4).toFixed(2)}px)`\n                  : undefined,\n            }}\n          >\n            <RemocnLogo fontSize={logoSize} color={c.logo} />\n          </div>\n        </div>\n      </div>\n    );\n\n    const ghosts = screenBlur > 0.8 ? [1.04, 1.09] : [];\n    content = (\n      <AbsoluteFill\n        style={{\n          filter:\n            screenBlur > 0.1 ? `blur(${screenBlur.toFixed(2)}px)` : undefined,\n        }}\n      >\n        {ghosts.map((g, gi) => layer(g, 0.2 - gi * 0.08, `ghost${gi}`))}\n        {layer(1, 1, \"main\")}\n      </AbsoluteFill>\n    );\n  }\n\n  return (\n    <AbsoluteFill style={{ background: bg, overflow: \"hidden\" }}>\n      <GshGround\n        preset={preset}\n        params={gParams}\n        timeOffset={c.groundOffset}\n        timeScale={0.6}\n        style={{\n          transform: `scale(${(1.12 - 0.12 * flood).toFixed(4)}) translateY(${((1 - flood) * H * 0.06).toFixed(2)}px)`,\n        }}\n      />\n      <AbsoluteFill\n        style={{\n          background: `radial-gradient(${portrait ? \"95% 60%\" : \"75% 80%\"} at 50% 46%, rgba(${c.veil},${(veil + 0.25 * (1 - veil)).toFixed(3)}) 0%, rgba(${c.veil},${veil.toFixed(3)}) 100%)`,\n        }}\n      />\n      {content}\n    </AbsoluteFill>\n  );\n}\n\nexport default WordPullback;\n",
      "type": "registry:component",
      "target": "components/remocn/word-pullback.tsx"
    }
  ],
  "type": "registry:component"
}
