{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-launch",
  "title": "Product Hero",
  "description": "Cinematic product-launch hero — two cards slide into formation as the headline reveals above.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/hero-launch/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadInter } from \"@remotion/google-fonts/Inter\";\nimport { type CSSProperties, useCallback, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  OffthreadVideo,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  mixOklch,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\nconst { fontFamily: SANS } = loadInter(\"normal\", {\n  weights: [\"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport interface HeroLaunchProps {\n  /** Reveal caption under the cards. */\n  heading?: string;\n  /**\n   * Left card media. An image URL, a video URL (`.mp4`/`.webm`/`.mov` → plays\n   * via <OffthreadVideo>), or a CSS `linear-gradient(...)`. The video editor\n   * swaps this for the viewer's own image/clip.\n   */\n  image1?: string;\n  /** Right card media (same rules as `image1`). */\n  image2?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  /** Defaults to `\"dark\"` — the scene is a cinematic stage, lit for dark. */\n  mode?: \"light\" | \"dark\";\n}\n\nconst PERSP = 1000;\n// Pure rotateY about each card's INNER edge — no rotateX, so the inner edges\n// stay vertical and the centre gap is a clean, even channel top-to-bottom.\nconst TILT = 17;\nconst GAP = 92;\n/** Stand-in for missing media — the page lifted a shade, not a fixed grey. */\nconst placeholder = (t: SnapCnTheme) =>\n  `linear-gradient(135deg,${mixOklch(t.background, t.card, 0.55)},${mixOklch(\n    t.background,\n    t.card,\n    0.2,\n  )})`;\n\n// Sample product-reveal clips. Absolute, because these ship as *defaults* into a\n// project that does not have them in its own `public/` — a root-relative default\n// becomes `staticFile()` in a render and 404s the first time anyone renders the\n// component they just installed. Swap them for your own media, root-relative or\n// not; `resolveSrc` handles both.\nconst SHOWCASE = [\n  \"https://snapcn.dev/showcase-videos/iphone-17-reveal.mp4\",\n  \"https://snapcn.dev/showcase-videos/airpods-pro.mp4\",\n];\n\nconst isVideo = (src: string) => /\\.(mp4|webm|mov|m4v)(\\?|$)/i.test(src);\nconst isGradient = (src: string) => src.startsWith(\"linear-gradient\");\n\n// easeOutQuint — quick to move, silky to settle, lands exactly on target with\n// no asymptotic \"stuck\" tail (the thing overdamped springs get wrong).\nconst OUT = Easing.bezier(0.22, 1, 0.36, 1);\n// Gravity-ish accelerate for the drop-off.\nconst FALL = Easing.bezier(0.5, 0, 0.9, 0.4);\n\n/**\n * A root-relative asset (\"/showcase-assets/..\") is served at the origin root by\n * Next in the Player, but a server render serves `public/` through\n * `staticFile()` — so rewrite local paths only while rendering, and pass\n * http(s)/data/blob URLs straight through.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (isLocal && getRemotionEnvironment().isRendering) {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  }\n  return src;\n}\n\n/**\n * Image that holds the server render until it has actually *loaded*, then\n * releases. Remotion's <Img> is avoided on purpose: it awaits `img.decode()`,\n * which the headless compositor rejects for some progressive JPEGs and hangs the\n * export. A plain <img> paints fine — we wait for `onload` ourselves and clear\n * on error too, so a bad source degrades gracefully. In the Player this is an\n * ordinary, instantly-shown image.\n */\nfunction HeroImage({ src, style }: { src: string; style: CSSProperties }) {\n  const [handle] = useState(() => delayRender(`hero image: ${src}`));\n  const release = useCallback(() => continueRender(handle), [handle]);\n  return (\n    // biome-ignore lint/performance/noImgElement: Remotion frame, not a Next route.\n    <img\n      src={resolveSrc(src)}\n      alt=\"\"\n      style={style}\n      onLoad={release}\n      onError={release}\n    />\n  );\n}\n\n/** Fills a card edge-to-edge with an image, a video, or a gradient. */\nfunction CardMedia({ src, t }: { src: string; t: SnapCnTheme }) {\n  const media: CSSProperties = {\n    position: \"absolute\",\n    inset: 0,\n    width: \"100%\",\n    height: \"100%\",\n    objectFit: \"cover\",\n  };\n  const effective = src || placeholder(t);\n  if (isGradient(effective)) {\n    return <div style={{ ...media, background: effective }} />;\n  }\n  if (isVideo(effective)) {\n    return <OffthreadVideo src={resolveSrc(effective)} muted style={media} />;\n  }\n  return <HeroImage src={effective} style={media} />;\n}\n\n/**\n * Cinematic product-launch hero — deterministic Remotion composition\n * (1280×720 @30fps). Two image cards, one continuous professionally-eased\n * motion, no bounce:\n *\n *   f0–40    the left image HOLDS full-screen, flat and clear\n *   f40      it recedes to its floating size and angles in about its inner edge\n *   f60      the right image slides in from the right; the two sit SYMMETRIC\n *            about the frame centre with an even, centred gap\n *   f100     both breathe with a barely-there float (never dead-static)\n *   f122     they fall off the lower edge together\n *   f126     the headline reveals and eases forward (\"comes to front\")\n *\n * Left/right/bottom edges dissolve into black. `image1`/`image2` + `heading`\n * are the props the video editor drives.\n */\nexport function HeroLaunch({\n  heading = \"npx shadcn add @snap-cn\",\n  image1 = SHOWCASE[0],\n  image2 = SHOWCASE[1],\n  theme,\n  mode,\n}: HeroLaunchProps) {\n  const frame = useCurrentFrame();\n  const { width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode ?? \"dark\");\n  const s = height / 720;\n\n  const cardW = 1000 * s;\n  const cardH = 620 * s;\n  const centerLeft = (width - cardW) / 2;\n  const cardTop = 0.3 * height;\n  // Symmetric about the centre → the gap sits exactly on the frame's midline.\n  const restOffset = (cardW + GAP * s) / 2;\n  const leftXFinal = -restOffset;\n  const rightXFinal = restOffset;\n  const rightXStart = 1.08 * width;\n  const groupYFinal = 0.16 * height;\n\n  // A clamped, eased 0→1 segment — the single motion primitive for the whole\n  // scene. Easing lands cleanly on the target, so nothing ever \"sticks\".\n  const seg = (start: number, dur: number, easing = OUT) =>\n    interpolate(frame, [start, start + dur], [0, 1], {\n      easing,\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    });\n\n  const recede = seg(40, 30); // full-screen → floating\n  const angle = seg(46, 36); // flat → angled (rotateY only)\n  const slideL = seg(46, 36); // centre → left rest\n  const slideR = seg(60, 40); // offscreen right → right rest\n  const drop = seg(122, 34, FALL); // settle → fall off bottom\n  const title = seg(126, 40); // headline reveal + forward\n\n  const leftScale = interpolate(recede, [0, 1], [1.5, 1]);\n  const leftX = interpolate(slideL, [0, 1], [0, leftXFinal]);\n  const leftDeg = interpolate(angle, [0, 1], [0, -TILT]);\n  const leftOpacity = interpolate(frame, [2, 12], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  const rightX = interpolate(slideR, [0, 1], [rightXStart, rightXFinal]);\n  const rightOpacity = interpolate(slideR, [0, 0.35], [0, 1], {\n    extrapolateRight: \"clamp\",\n  });\n\n  // Barely-there float once settled (fades out into the drop) so the held beat\n  // reads as alive, not frozen — the classic \"why does it feel stuck\" fix.\n  const floatEnv = seg(94, 22) * (1 - drop);\n  const floatY = Math.sin(frame * 0.09) * 3.5 * s * floatEnv;\n  const groupY = interpolate(drop, [0, 1], [0, groupYFinal]) + floatY;\n\n  const titleY = interpolate(title, [0, 1], [40 * s, 0]);\n  const titleScale = interpolate(title, [0, 1], [0.82, 1]);\n  const titleBlur = interpolate(title, [0, 1], [10, 0]);\n\n  const vignette = seg(50, 40, Easing.linear);\n\n  const cardBox = (x: number, opacity: number): CSSProperties => ({\n    position: \"absolute\",\n    left: centerLeft,\n    top: cardTop,\n    width: cardW,\n    height: cardH,\n    opacity,\n    transform: `translateX(${x}px)`,\n  });\n\n  // Opening zoom pivots on the centre; the tilt pivots on the inner edge.\n  const zoom = (scale: number): CSSProperties => ({\n    width: \"100%\",\n    height: \"100%\",\n    transform: `scale(${scale})`,\n    transformOrigin: \"center center\",\n  });\n  const tilt = (deg: number, origin: string): CSSProperties => ({\n    width: \"100%\",\n    height: \"100%\",\n    transform: `perspective(${PERSP * s}px) rotateY(${deg}deg)`,\n    transformOrigin: origin,\n  });\n  const fade = (gradient: string): CSSProperties => ({\n    position: \"absolute\",\n    inset: 0,\n    background: gradient,\n  });\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: t.background, fontFamily: SANS }}>\n      <AbsoluteFill style={{ transform: `translateY(${groupY}px)` }}>\n        <div style={cardBox(leftX, leftOpacity)}>\n          <div style={zoom(leftScale)}>\n            {/* inner edge = right edge → pivot at 100% 50% */}\n            <div style={tilt(leftDeg, \"100% 50%\")}>\n              <ImageCard s={s} src={image1} t={t} />\n            </div>\n          </div>\n        </div>\n        <div style={cardBox(rightX, rightOpacity)}>\n          {/* inner edge = left edge → pivot at 0% 50% */}\n          <div style={tilt(TILT, \"0% 50%\")}>\n            <ImageCard s={s} src={image2} t={t} />\n          </div>\n        </div>\n      </AbsoluteFill>\n\n      {/* Wide, clean edge dissolve — fades in after the full-screen opening. */}\n      <AbsoluteFill style={{ opacity: vignette, pointerEvents: \"none\" }}>\n        <div\n          style={fade(\n            `linear-gradient(to left, ${t.background} 0%, transparent 30%)`,\n          )}\n        />\n        <div\n          style={fade(\n            `linear-gradient(to right, ${t.background} 0%, transparent 22%)`,\n          )}\n        />\n        <div\n          style={fade(\n            `linear-gradient(to top, ${t.background} 0%, transparent 30%)`,\n          )}\n        />\n      </AbsoluteFill>\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          right: 0,\n          top: 0.22 * height,\n          padding: `0 ${40 * s}px`,\n          textAlign: \"center\",\n          color: t.foreground,\n          fontWeight: 600,\n          letterSpacing: \"-0.02em\",\n          fontSize: 42 * s,\n          opacity: title,\n          filter: `blur(${titleBlur}px)`,\n          transform: `translateY(${titleY}px) scale(${titleScale})`,\n        }}\n      >\n        {heading}\n      </div>\n    </AbsoluteFill>\n  );\n}\n\n/** A single card: one image (or video/gradient) filling it edge-to-edge. */\nfunction ImageCard({ s, src, t }: { s: number; src: string; t: SnapCnTheme }) {\n  return (\n    <div\n      style={{\n        position: \"relative\",\n        width: \"100%\",\n        height: \"100%\",\n        overflow: \"hidden\",\n        borderRadius: 30 * s,\n        background: mixOklch(t.background, t.card, 0.35),\n        // Drop shadow + a subtle inset hairline ring so the edge reads on both\n        // dark and bright media without dominating.\n        boxShadow: `0 ${20 * s}px ${52 * s}px -${28 * s}px ${withAlpha(\n          mixOklch(t.background, \"#000\", 0.75),\n          0.9,\n        )}, inset 0 0 0 1px ${withAlpha(t.foreground, 0.22)}`,\n      }}\n    >\n      <CardMedia src={src} t={t} />\n      {/* subtle depth: darken the lower edge + a hairline top sheen */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `linear-gradient(to top, ${withAlpha(\n            mixOklch(t.background, \"#000\", 0.75),\n            0.28,\n          )} 0%, transparent 32%)`,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          left: 26 * s,\n          right: 26 * s,\n          top: 0,\n          height: 1,\n          background: `linear-gradient(90deg,transparent,${withAlpha(\n            t.foreground,\n            0.5,\n          )},transparent)`,\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/hero-launch.tsx"
    }
  ],
  "type": "registry:component"
}