{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "milestone",
  "type": "registry:block",
  "title": "Milestone",
  "description": "Real numeric achievement explicitly in the input: users, revenue, years, installs, customers, stars, or adoption count. Use exact targetNumber; never for generic releases, versions, 'free', or proof without an achievement count.",
  "dependencies": [
    "react"
  ],
  "registryDependencies": [
    "@vanillasky/backgrounds",
    "@vanillasky/motion",
    "@vanillasky/theme",
    "@vanillasky/typography",
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/scene-templates/social-milestone.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/social-milestone.tsx",
      "content": "/**\n * social-milestone — follower/subscriber count rolling up to a milestone with celebration.\n *\n * Converted from Remotion FollowerMilestone. Counter rolls up to target number,\n * glow intensifies, then confetti burst + celebration badge pop on hit.\n *\n * Block structure (docs/blocks.md):\n *   background — SceneBackground (brand gradient / Pexels media + scrims)\n *   hero       — MilestoneBadge primitive (rolling number + glow + confetti\n *                + celebration pill)\n *   caption    — the uppercase label inside MilestoneBadge (no TemplateText)\n */\n\nimport type { VariableField } from \"../video-config\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps, mediaBackgroundSchemaFields, mediaBackgroundDefaults } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { MilestoneBadge } from \"../primitives/social/MilestoneBadge\";\n\nexport const socialMilestoneSchema: Record<string, VariableField> = {\n  label: {\n    type: \"string\",\n    label: \"Label\",\n    default: \"Followers\",\n    required: true,\n    description: \"Label above the number (e.g. Followers, Subscribers, Downloads)\",\n  },\n  targetNumber: {\n    type: \"number\",\n    label: \"Target number\",\n    default: 10000,\n    required: true,\n    description: \"The milestone number to reach\",\n  },\n  startNumber: {\n    type: \"number\",\n    label: \"Start number\",\n    default: \"\",\n    description: \"Number the counter starts rolling from. Leave empty to auto-calculate (~98% of target).\",\n  },\n  badgeText: {\n    type: \"string\",\n    label: \"Badge text\",\n    default: \"10K Followers!\",\n    description: \"Celebration badge text that pops in at milestone\",\n  },\n  badgeEmoji: {\n    type: \"string\",\n    label: \"Badge emoji\",\n    default: \"🎉\",\n    description: \"Emoji shown in the celebration badge\",\n  },\n  badgeColor: {\n    type: \"color\",\n    label: \"Badge color\",\n    default: \"#facc15\",\n    description: \"Background color of the celebration badge\",\n  },\n  ...mediaBackgroundSchemaFields,\n};\n\nexport const socialMilestoneDefaults: Record<string, unknown> = {\n  label: \"Followers\",\n  targetNumber: 10000,\n  startNumber: \"\",\n  badgeText: \"10K Followers!\",\n  badgeEmoji: \"🎉\",\n  badgeColor: \"#facc15\",\n  ...mediaBackgroundDefaults,\n};\n\nexport const SocialMilestoneTemplate: React.FC<SceneTemplateProps> = ({\n  variables,\n  style,\n  progress,\n  beatIntensity,\n  width,\n  height,\n  sceneDuration,\n  isPlaying = true,\n  backgroundEffect,\n}) => {\n  // Accent fallback migrated from the historical #3b82f6 to the canonical token.\n  const { accent, font, explicit } = resolveTokens(style);\n\n  const label = stripPipe(String(variables.label || \"Followers\"));\n  const gradSeed = label.split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\n  const targetNumber = Number(variables.targetNumber) || 10000;\n  const rawStart = variables.startNumber != null ? Number(variables.startNumber) : undefined;\n  const badgeText = stripPipe(String(variables.badgeText || \"10K Followers!\"));\n  const badgeEmoji = String(variables.badgeEmoji || \"🎉\");\n\n  // Media-mode legibility: when a Pexels photo/video is behind, MilestoneBadge\n  // forces the label to full white and stacks a stronger drop-shadow on\n  // number + label so they punch through busy footage. SceneBackground\n  // already adds a vignette + bottom scrim. Mirrors bigNumber's treatment.\n  const hasMedia = !!String(variables.mediaUrl || \"\").trim() &&\n    String(variables.mediaType || \"auto\") !== \"gradient\";\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        backgroundColor: \"#000\",\n        position: \"relative\",\n        overflow: \"hidden\",\n        fontFamily: font,\n        display: \"flex\",\n        justifyContent: \"center\",\n        alignItems: \"center\",\n      }}\n    >\n      {/* [slot: background] Gradient background — supports Pexels media when mediaUrl is set */}\n      <SceneBackground\n        style={style}\n        progress={progress}\n        sceneDuration={sceneDuration}\n        width={width}\n        height={height}\n        {...getMediaBackgroundProps(variables)}\n        backgroundEffect={backgroundEffect}\n        seed={gradSeed}\n        isPlaying={isPlaying}\n        beatIntensity={beatIntensity}\n      />\n      {/* [slot: hero] Rolling counter + label + confetti + celebration pill —\n          shared primitive (owns the milestone timing windows) */}\n      <MilestoneBadge\n        progress={progress}\n        width={width}\n        height={height}\n        targetNumber={targetNumber}\n        label={label}\n        startNumber={rawStart}\n        badgeText={badgeText}\n        badgeEmoji={badgeEmoji}\n        accent={accent}\n        hasMedia={hasMedia}\n        muted={explicit.muted}\n        beatIntensity={beatIntensity}\n        confettiBgColor={style.brandKit?.secondary}\n      />\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/primitives/social/MilestoneBadge.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/social/MilestoneBadge.tsx",
      "content": "/**\n * MilestoneBadge — numeric achievement badge with rolling counter, glow,\n * confetti burst, and celebration pill.\n *\n * The\n * template composes this component, so the two can no longer drift.\n * The primitive owns:\n *   - the big rolling number with auto-fit font scale\n *   - the uppercase label above\n *   - the radial glow that ramps up as the count nears target\n *   - the confetti burst (via shared ConfettiLayer) that fires on hit\n *   - the celebration pill (\"🎉 10K Followers!\") that pops in on hit\n *\n * It does NOT own the SceneBackground gradient/media — that stays in the\n * scene composer so the same badge can render over brand gradient or\n * Pexels footage.\n *\n * Distinct from CountUpNumber: this primitive has badge framing\n * (label + pill + confetti) baked in.\n *\n * Props:\n *  - progress        — scene progress 0..1\n *  - width / height  — frame dimensions\n *  - targetNumber    — milestone to reach\n *  - label           — uppercase label above the number (e.g. \"Followers\")\n *  - prefix / suffix — optional decoration around the number (e.g. \"$\", \"+\", \"K\")\n *  - startNumber     — counter origin. Defaults to ~98% of target so it\n *                      always rolls UP visibly.\n *  - badgeText       — celebration pill text (e.g. \"10K Followers!\")\n *  - badgeEmoji      — emoji shown in the pill (default 🎉)\n *  - badgeColor      — kept for API parity with the source schema\n *                      (the pill itself uses a translucent dark fill so\n *                      it reads on any background)\n *  - accent          — brand accent driving the glow color (default \"#00e5a0\")\n *  - hasMedia        — when true, use the media-safe text treatment\n *                      (full white label + drop shadows). Defaults to false.\n *  - muted           — explicit brand-kit muted color for the label\n *                      (gradient mode only; media mode forces white).\n *  - beatIntensity   — optional 0..1 audio reactivity (subtle scale pop)\n *  - confettiBgColor — optional confetti hue-filter input (parity with source)\n */\n\nimport * as React from \"react\";\nimport {\n  interpolate,\n  spring,\n  SPRING_SMOOTH,\n  SPRING_BOUNCY,\n} from \"../../motion\";\nimport { withOpacity } from \"../../theme\";\nimport { stripPipe } from \"../../typography\";\nimport { Emoji } from \"../../emoji\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\nimport { ConfettiLayer } from \"../../scene-templates/confetti-layer\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\" as const,\n  extrapolateRight: \"clamp\" as const,\n};\n\nexport interface MilestoneBadgeProps {\n  progress: number;\n  width: number;\n  height: number;\n  targetNumber: number;\n  label: string;\n  prefix?: string;\n  suffix?: string;\n  startNumber?: number;\n  badgeText?: string;\n  badgeEmoji?: string;\n  badgeColor?: string;\n  accent?: string;\n  hasMedia?: boolean;\n  /** Explicit brand-kit muted color for the label (gradient mode). */\n  muted?: string;\n  beatIntensity?: number;\n  confettiBgColor?: string;\n}\n\nexport const MilestoneBadge: React.FC<MilestoneBadgeProps> = ({\n  progress,\n  width,\n  height,\n  targetNumber,\n  label: rawLabel,\n  prefix = \"\",\n  suffix = \"\",\n  startNumber,\n  badgeText: rawBadgeText = \"\",\n  badgeEmoji = \"🎉\",\n  accent = TOKEN_DEFAULTS.accent,\n  hasMedia = false,\n  muted,\n  beatIntensity = 0,\n  confettiBgColor,\n}) => {\n  const dim = Math.min(width, height);\n  const s = dim / 1080;\n\n  const label = stripPipe(rawLabel || \"\");\n  const badgeText = stripPipe(rawBadgeText || \"\");\n\n  const safeTarget = Number.isFinite(targetNumber) ? targetNumber : 0;\n  const safeStart =\n    startNumber != null && Number.isFinite(startNumber)\n      ? startNumber\n      : Math.max(0, Math.round(safeTarget * 0.98));\n\n  const labelColor = hasMedia ? \"#ffffff\" : (muted || \"rgba(255,255,255,0.8)\");\n  const mediaTextShadow = hasMedia\n    ? \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\"\n    : undefined;\n\n  // ── Timing (mirror social-milestone source) ────────────────────\n  // Counter rolls from progress 0.056 to 0.556\n  const followerCount = Math.round(\n    interpolate(progress, [0.056, 0.556], [safeStart, safeTarget], CLAMP),\n  );\n  const hasHitTarget = followerCount >= safeTarget;\n\n  const numberScale = spring(\n    interpolate(progress, [0, 0.25], [0, 1], CLAMP),\n    SPRING_SMOOTH,\n  );\n  const glowIntensity = interpolate(progress, [0.33, 0.556], [0, 1], CLAMP);\n  const labelOpacity = interpolate(progress, [0, 0.167], [0, 1], CLAMP);\n\n  const badgeP = hasHitTarget\n    ? spring(\n        interpolate(progress, [0.556, 0.85], [0, 1], CLAMP),\n        SPRING_BOUNCY,\n      )\n    : 0;\n  const badgeScale = interpolate(badgeP, [0, 0.5, 1], [0, 1.2, 1], CLAMP);\n  const badgeOpacity = badgeP;\n\n  const confettiActive = hasHitTarget;\n  const confettiProgress = confettiActive\n    ? Math.max(0, (progress - 0.556) / 0.444)\n    : 0;\n\n  const beatScale = 1 + beatIntensity * 0.02;\n\n  const numberDisplay = `${prefix}${followerCount.toLocaleString()}${suffix}`;\n  // Auto-scale thresholds match social-milestone (the tuned source of truth).\n  const fontSize =\n    numberDisplay.length > 9\n      ? dim * 0.09\n      : numberDisplay.length > 6\n        ? dim * 0.12\n        : dim * 0.16;\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        justifyContent: \"center\",\n        alignItems: \"center\",\n      }}\n    >\n      {/* Confetti — shared 200-particle layer, fires on milestone hit */}\n      {confettiActive && (\n        <ConfettiLayer\n          progress={confettiProgress}\n          width={width}\n          height={height}\n          beatIntensity={beatIntensity}\n          bgColor={hasMedia ? undefined : confettiBgColor || accent}\n        />\n      )}\n\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          gap: dim * 0.015,\n          transform: `scale(${numberScale * beatScale})`,\n          position: \"relative\",\n          zIndex: 1,\n          width: \"100%\",\n        }}\n      >\n        {/* Label */}\n        {label && (\n          <div\n            style={{\n              color: labelColor,\n              fontSize: dim * 0.032,\n              fontWeight: 500,\n              textTransform: \"uppercase\",\n              letterSpacing: dim * 0.004,\n              opacity: labelOpacity,\n              textAlign: \"center\",\n              ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n            }}\n          >\n            {label}\n          </div>\n        )}\n\n        {/* Big number */}\n        <div\n          style={{\n            color: \"#ffffff\",\n            fontSize,\n            fontWeight: 800,\n            letterSpacing: dim * -0.004,\n            lineHeight: 1,\n            position: \"relative\",\n            maxWidth: \"90%\",\n            textAlign: \"center\",\n            ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n          }}\n        >\n          {/* Glow behind number */}\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"50%\",\n              left: \"50%\",\n              transform: \"translate(-50%, -50%)\",\n              width: dim * 0.5,\n              height: dim * 0.2,\n              borderRadius: \"50%\",\n              background: `radial-gradient(ellipse, ${withOpacity(accent, glowIntensity * 0.2)} 0%, ${withOpacity(accent, glowIntensity * 0.05)} 50%, transparent 70%)`,\n              pointerEvents: \"none\",\n            }}\n          />\n          <span style={{ position: \"relative\" }}>{numberDisplay}</span>\n        </div>\n\n        {/* Celebration badge */}\n        {badgeText && (\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"100%\",\n              left: \"50%\",\n              transform: `translateX(-50%) scale(${badgeScale})`,\n              opacity: badgeOpacity,\n              backgroundColor: \"rgba(0,0,0,0.3)\",\n              border: \"1px solid rgba(255,255,255,0.15)\",\n              borderRadius: 100 * s,\n              padding: `${dim * 0.015}px ${dim * 0.037}px`,\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              gap: dim * 0.011,\n              whiteSpace: \"nowrap\",\n              marginTop: dim * 0.02,\n            }}\n          >\n            <Emoji char={badgeEmoji} size={dim * 0.045} verticalAlign=\"middle\" />\n            <span\n              style={{\n                color: \"#ffffff\",\n                fontSize: dim * 0.036,\n                fontWeight: 700,\n                letterSpacing: dim * 0.0005,\n              }}\n            >\n              {renderWithEmoji(badgeText, dim * 0.036)}\n            </span>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/confetti-layer.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/confetti-layer.tsx",
      "content": "/**\n * confetti-layer — reusable confetti burst layer.\n *\n * Extracted from bg-confetti.tsx so any host template can compose the same\n * physics on top of its own backdrop. Used by:\n *  - bg-confetti.tsx (the standalone celebration template — gradient bg)\n *  - bg-media.tsx     (conditional via the `confetti` boolean — confetti\n *    layered over photo / video / gradient)\n *\n * Physics: 200 particles, even angular fan-out (360°) from center.\n * Particles move RADIALLY outward — both x and y follow the burst angle so\n * each particle exits the frame in its launch direction (top, sides, bottom\n * corners). A very gentle gravity tugs them slightly downward over time so\n * the motion still reads as natural, but it's never strong enough to reverse\n * an upward trajectory before the particle exits the frame. 3D tumble\n * approximated via scaleX flip. Progress-driven — deterministic for export.\n */\n\nimport React from \"react\";\n\nconst PARTICLE_COUNT = 200;\n\n// Confetti palette spans the full hue wheel; filtered at runtime to drop any\n// color too close in hue to the background (and would disappear).\nconst COLORS = [\"#ef4444\", \"#ff6b6b\", \"#f97316\", \"#fb923c\", \"#ffd93d\", \"#34d399\", \"#6bcbff\", \"#818cf8\", \"#a78bfa\", \"#f472b6\", \"#ffffff\"];\n\nfunction seededRandom(seed: number): number {\n  const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;\n  return x - Math.floor(x);\n}\n\n/** Outward sweep — softer ease-out quad. Less front-loaded than cubic so\n *  the burst reads as a graceful arc rather than an explosion-and-stop. */\nfunction easeOutQuad(p: number): number {\n  return 1 - (1 - p) * (1 - p);\n}\n\nfunction hexHue(hex: string): number {\n  const r = parseInt(hex.slice(1, 3), 16) / 255;\n  const g = parseInt(hex.slice(3, 5), 16) / 255;\n  const b = parseInt(hex.slice(5, 7), 16) / 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  const d = max - min;\n  if (d === 0) return 0;\n  let h = 0;\n  if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n  else if (max === g) h = ((b - r) / d + 2) / 6;\n  else h = ((r - g) / d + 4) / 6;\n  return h * 360;\n}\n\nfunction hueDistance(a: number, b: number): number {\n  const d = Math.abs(a - b) % 360;\n  return d > 180 ? 360 - d : d;\n}\n\nexport interface ConfettiLayerProps {\n  progress: number;\n  width: number;\n  height: number;\n  beatIntensity?: number;\n  /**\n   * Optional background tone for hue-filtering. When provided, palette\n   * colors within 35° of this hue are dropped so confetti doesn't blend\n   * into a same-hue gradient. Pass the bg's dominant color (typically\n   * secondary or accent). Omit for media-bg cases (photos/videos have no\n   * single dominant color); the full palette will be used.\n   */\n  bgColor?: string;\n}\n\nexport const ConfettiLayer: React.FC<ConfettiLayerProps> = ({\n  progress,\n  width,\n  height,\n  beatIntensity = 0,\n  bgColor,\n}) => {\n  const s = Math.min(width, height) / 1080;\n  /* Radial burst — each particle flies out in its launch direction until\n   * it leaves the frame. Reach is sized off the LONG edge so even\n   * particles aimed along the long axis (top/bottom in portrait, sides\n   * in landscape) clear the frame edge by progress=1. Half-diagonal of\n   * a 9:16 frame ≈ 0.61 * longEdge, so 0.95 gives ~35% headroom past\n   * the corner — particles exit cleanly with a few hundred px of trail. */\n  const BURST_RADIUS = Math.max(width, height) * 0.95;\n\n  // Filter palette only if a bgColor is provided (gradient context). Over\n  // photos/videos we keep the full palette since there's no single bg hue.\n  let allColors = COLORS;\n  if (bgColor) {\n    const bgHue = hexHue(bgColor);\n    const filtered = COLORS.filter(c => c === \"#ffffff\" || hueDistance(hexHue(c), bgHue) > 35);\n    if (filtered.length >= 4) allColors = filtered;\n  }\n\n  return (\n    <>\n      {Array.from({ length: PARTICLE_COUNT }, (_, i) => {\n        const seed = i + 1;\n\n        // Even angular distribution — single biggest \"feel\" win vs random.\n        const angle = (i / PARTICLE_COUNT) * Math.PI * 2;\n\n        // Per-particle variance\n        const forceFactor    = 0.75 + seededRandom(seed * 1) * 0.5;   // 0.75..1.25\n        const durationFactor = 1.1  + seededRandom(seed * 3) * 0.6;   // 1.1..1.7 — slow arc\n        const color          = allColors[i % allColors.length];\n        const isCircle       = seededRandom(seed * 4) > 0.85;\n        const startRot       = seededRandom(seed * 5) * 360;\n        const rotSpeed       = (seededRandom(seed * 6) - 0.5) * 1440;\n        const flipSpeed      = 3 + seededRandom(seed * 7) * 4;\n        const flipPhase      = seededRandom(seed * 8) * Math.PI * 2;\n        const widthFactor    = 0.45 + seededRandom(seed * 9) * 0.6;\n        const heightFactor   = 0.7  + seededRandom(seed * 10) * 0.6;\n\n        const p = Math.min(1, progress / durationFactor);\n\n        // Radial burst — particles fly outward in their launch direction.\n        // Outward distance eases out so they decelerate slightly near the\n        // frame edge but don't reverse. A small quadratic gravity term tugs\n        // them downward over time without overriding upward velocity before\n        // they exit the frame.\n        const reach   = BURST_RADIUS * forceFactor;\n        const radialP = easeOutQuad(p);\n        const gravity = height * 0.08 * p * p;\n        const x = width * 0.5 + Math.cos(angle) * reach * radialP;\n        const y = height * 0.5 + Math.sin(angle) * reach * radialP + gravity;\n\n        const flip = Math.cos(p * Math.PI * flipSpeed + flipPhase);\n        const rot  = startRot + rotSpeed * p;\n\n        const baseSize = (8 + seededRandom(seed * 11) * 10) * s;\n        const w = baseSize * widthFactor;\n        const h = isCircle ? w : baseSize * heightFactor;\n\n        const popScale = Math.min(1, p * 30);\n        const opacity = p < 0.85 ? 1 : Math.max(0, 1 - (p - 0.85) / 0.15);\n        const beatPulse = 1 + beatIntensity * 0.15;\n\n        if (opacity <= 0) return null;\n        if (y > height + 120 || y < -120 || x < -120 || x > width + 120) return null;\n\n        return (\n          <div\n            key={i}\n            style={{\n              position: \"absolute\",\n              left: x - w / 2,\n              top: y - h / 2,\n              width: w,\n              height: h,\n              borderRadius: isCircle ? \"50%\" : 1,\n              backgroundColor: color,\n              opacity,\n              transform: `rotate(${rot}deg) scaleX(${flip}) scale(${popScale * beatPulse})`,\n              pointerEvents: \"none\",\n            }}\n          />\n        );\n      })}\n    </>\n  );\n};\n"
    },
    {
      "path": "src/lib/emoji/emoji-text.tsx",
      "type": "registry:component",
      "target": "vanillasky/emoji/emoji-text.tsx",
      "content": "/**\n * EmojiText / renderWithEmoji — split a string into text runs + color-emoji\n * runs, rendering each color emoji as an inline emoji-PNG <img> (via <Emoji>)\n * while leaving everything else as plain text.\n *\n * Why this exists: raw-unicode emoji fall through to the OS emoji font (Mac =\n * Apple, Linux = Noto), so the three render paths (preview / client SVG export\n * / server Puppeteer) disagree. Wrapping every template/AI-supplied string here\n * makes ALL color emoji render as the same mapped PNG in all three.\n *\n * What it does NOT touch: monochrome symbols (★ ✦ ✓ → ↑ …). Those render\n * identically as text across OSes, and imaging them would be wrong. The gate\n * is `emojiToDataUri()` — only clusters that resolve to a PNG in the\n * map become <img>; anything else (monochrome symbols, unmapped/custom\n * emoji, skin-tone variants not in the set) stays raw text.\n *\n * Segmentation: grapheme clusters via Intl.Segmenter so ZWJ sequences\n * (👩‍💻) and skin-tone modifiers (👍🏽) stay single units. Adjacent emoji\n * clusters each render as their own <img> in sequence.\n *\n * Sizing: each emoji <img> is sized to `fontSizePx` (the surrounding font\n * size) so the emoji box matches a glyph box — width/line-count stay\n * ~identical to the raw-text layout, which keeps fitTextSize / archetype\n * char-count measurement valid (all string-based, never DOM-measured).\n */\n\nimport * as React from \"react\";\nimport { Emoji, emojiToDataUri } from \"./index\";\n\n// Coarse \"is this cluster a color-emoji candidate?\" test. Deliberately broad:\n// the real decision is whether emojiToDataUri() resolves it to a mapped PNG.\n// This regex only needs to (a) catch the OS-divergent color ranges and (b)\n// avoid flagging plain text/whitespace so we don't merge text into emoji runs.\n//\n//  - \\u{1F000}-\\u{1FAFF}  — main emoji planes (most pictographs)\n//  - \\u{2600}-\\u{27BF}    — misc symbols + dingbats (color when + FE0F)\n//  - \\u{2B00}-\\u{2BFF}    — stars/arrows block (⭐ lives here)\n//  - \\u{1F1E6}-\\u{1F1FF}  — regional indicators (flags)\n//  - \\u{1F3FB}-\\u{1F3FF}  — skin-tone modifiers\n//  - \\u{FE0F} | \\u{200D} | \\u{20E3} — VS16 / ZWJ / keycap combiners\n//\n// Combining/joiner codepoints (FE0F, ZWJ, keycap) are matched via alternation\n// rather than inside the character class — eslint's\n// no-misleading-character-class flags combining chars inside `[...]`, and\n// alternation is equivalent here (we only test, never capture).\nconst EMOJI_CANDIDATE =\n  /[\\u{1F000}-\\u{1FAFF}\\u{2600}-\\u{27BF}\\u{2B00}-\\u{2BFF}\\u{1F1E6}-\\u{1F1FF}]|[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{200D}|\\u{20E3}/u;\n\nfunction isEmojiCandidate(cluster: string): boolean {\n  return EMOJI_CANDIDATE.test(cluster);\n}\n\n/** Split text into grapheme clusters (ZWJ + skin-tone safe). */\nfunction toGraphemes(text: string): string[] {\n  const Seg = (Intl as unknown as { Segmenter?: typeof Intl.Segmenter }).Segmenter;\n  if (Seg) {\n    const seg = new Seg(undefined, { granularity: \"grapheme\" });\n    const out: string[] = [];\n    for (const { segment } of seg.segment(text)) out.push(segment);\n    return out;\n  }\n  // Fallback: code-point spread. Won't keep ZWJ/skin-tone as single units, so\n  // those land as separate clusters; emojiToDataUri's FE0F tolerance + the raw\n  // fallback keep them from breaking. Modern browsers all have Segmenter.\n  return Array.from(text);\n}\n\nexport interface RenderWithEmojiOptions {\n  /** vertical-align passed to each <Emoji>. */\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  /** Extra style merged onto each emoji <img>. */\n  emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Split `text` into an array of React nodes — plain-text strings interleaved\n * with <Emoji> images for every mapped color emoji. Returns a single-element\n * `[text]` fast-path when there are no color emoji (the common case), so\n * non-emoji text pays ~one regex test.\n *\n * @param text         the string to render\n * @param fontSizePx   surrounding font size in px (emoji box = this size)\n */\nexport function renderWithEmoji(\n  text: string,\n  fontSizePx: number,\n  opts?: RenderWithEmojiOptions,\n): React.ReactNode[] {\n  if (!text) return [text];\n  // Cheap bail-out: if the whole string has no emoji-range codepoint, return\n  // it untouched (no segmentation, no array churn) — the hot path for the vast\n  // majority of titles/bodies.\n  if (!EMOJI_CANDIDATE.test(text)) return [text];\n\n  const clusters = toGraphemes(text);\n  const nodes: React.ReactNode[] = [];\n  let textBuf = \"\";\n  let key = 0;\n\n  const flushText = () => {\n    if (textBuf) {\n      nodes.push(textBuf);\n      textBuf = \"\";\n    }\n  };\n\n  for (const cluster of clusters) {\n    const uri = isEmojiCandidate(cluster) ? emojiToDataUri(cluster) : null;\n    if (uri) {\n      flushText();\n      nodes.push(\n        <Emoji\n          key={`e${key++}`}\n          char={cluster}\n          size={fontSizePx}\n          verticalAlign={opts?.verticalAlign}\n          style={opts?.emojiStyle}\n        />,\n      );\n    } else {\n      textBuf += cluster;\n    }\n  }\n  flushText();\n  return nodes;\n}\n\n/**\n * Per-code-unit emoji plan for the typewriter archetype, which reveals text\n * one UTF-16 code unit at a time and indexes by `text.length`. We can't just\n * call renderWithEmoji there (it would re-segment and desync the visibleChars\n * counter), so this maps each cluster's START code-unit index to its emoji-PNG\n * data URI and marks the cluster's CONTINUATION indices as covered (render\n * nothing for them — the start index's <img> already spans the whole cluster).\n *\n * Returns null when the text contains no mapped color emoji (the common case),\n * so the typewriter render keeps its plain per-char path.\n */\nexport interface EmojiTypewriterPlan {\n  /** code-unit index → full emoji cluster char (cluster start). */\n  starts: Map<number, string>;\n  /** code-unit indices that are continuations of a cluster (render nothing). */\n  covered: Set<number>;\n}\n\nexport function planTypewriterEmoji(text: string): EmojiTypewriterPlan | null {\n  if (!text || !EMOJI_CANDIDATE.test(text)) return null;\n  const starts = new Map<number, string>();\n  const covered = new Set<number>();\n  let idx = 0;\n  let found = false;\n  for (const cluster of toGraphemes(text)) {\n    const len = cluster.length; // UTF-16 code units\n    if (isEmojiCandidate(cluster) && emojiToDataUri(cluster)) {\n      found = true;\n      // Store the WHOLE cluster char (not just the start unit) so callers can\n      // pass it straight to <Emoji> for a correct map lookup.\n      starts.set(idx, cluster);\n      for (let k = 1; k < len; k++) covered.add(idx + k);\n    }\n    idx += len;\n  }\n  return found ? { starts, covered } : null;\n}\n\nexport interface EmojiTextProps {\n  /** The text to render, color emoji replaced with emoji-PNG <img>. */\n  children: string;\n  /** Surrounding font size in px — sizes each emoji image to match a glyph. */\n  fontSize: number;\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Inline wrapper around renderWithEmoji. Renders a React.Fragment of text +\n * emoji <img> runs. Use where a component currently renders a raw `{text}`\n * child and you have the surrounding font size in px.\n */\nexport const EmojiText: React.FC<EmojiTextProps> = ({\n  children,\n  fontSize,\n  verticalAlign,\n  emojiStyle,\n}) => {\n  const text = typeof children === \"string\" ? children : \"\";\n  return <>{renderWithEmoji(text, fontSize, { verticalAlign, emojiStyle })}</>;\n};\n"
    },
    {
      "path": "src/lib/emoji/index.tsx",
      "type": "registry:component",
      "target": "vanillasky/emoji/index.tsx",
      "content": "/**\n * Emoji — render emojis as inline PNG data-URI <img> elements.\n *\n * Why images, not the system emoji font: VanillaSky renders the same scene\n * three ways — real-DOM preview, client SVG-as-image export, server Puppeteer.\n * Raw-unicode emojis fall through to whatever emoji font the OS has (Mac=Apple,\n * Linux=Noto), so preview and export disagree. Inlining each emoji as a\n * data-URI <img> renders the SAME PNG in all three. Critically, the\n * client export rasterizes the frame via <img src=\"data:image/svg+xml\">, which\n * can ONLY load inlined data-URI images (not @font-face, not external URLs) —\n * so data URIs are exactly what works there.\n *\n * Source artwork: emoji-datasource-google (Google Noto emoji set, Apache-2.0).\n * The map ships inside the publicly redistributed skill tarball, and Apple's\n * artwork is not licensed for redistribution — Noto's Apache-2.0 is. The set\n * is swappable via the generator's EMOJI_SET constant\n * (scripts/generate-emoji-map.mjs).\n *\n * Bundle: the generated char→dataURI map is ~1.1MB of base64, so it is\n * LAZY-LOADED via dynamic import and never enters the initial app bundle. The\n * import is kicked off eagerly at module load (below) — long before any export\n * or screenshot — so by the time a frame is captured the map has resolved.\n * Until it resolves, <Emoji> renders the raw unicode char, then re-renders to\n * the image when the map lands. Any emoji not in the map also falls back to the\n * raw char, so nothing ever breaks for custom/unmapped emojis.\n */\n\nimport * as React from \"react\";\n\nlet EMOJI_MAP: Record<string, string> | null = null;\nlet loadPromise: Promise<Record<string, string>> | null = null;\nconst subscribers = new Set<() => void>();\n\nfunction loadEmojiMap(): Promise<Record<string, string>> {\n  if (loadPromise) return loadPromise;\n  loadPromise = import(\"./emoji-map.generated\")\n    .then((mod) => {\n      EMOJI_MAP = mod.default;\n      subscribers.forEach((fn) => fn());\n      return EMOJI_MAP;\n    })\n    .catch((err) => {\n      // Network/chunk failure: leave map null → everything falls back to char.\n      console.warn(\"[emoji] failed to load emoji map:\", err);\n      EMOJI_MAP = {};\n      return EMOJI_MAP;\n    });\n  return loadPromise;\n}\n\n// Kick off the load immediately when this module is first imported (app/registry\n// init), so the map is ready well before any export or server screenshot.\nloadEmojiMap();\n\n/**\n * Resolve once the emoji-PNG map has loaded (or definitively failed). Render\n * paths that capture frames MUST await this before their first capture so they\n * never screenshot a frame while emoji are still raw-unicode chars:\n *   - server: Render.tsx awaits before setting window.__ready\n *   - client export: export-video.ts awaits in the media-preload step\n * The preview path doesn't capture, so it can stay lazy (subscribers re-render\n * char → image when the map lands). On chunk failure this resolves to {} so\n * the await never hangs — everything falls back to the raw char.\n */\nexport function ensureEmojiMap(): Promise<Record<string, string>> {\n  return loadEmojiMap();\n}\n\n/**\n * Returns the emoji-PNG data URI for an emoji char, or null if the map hasn't\n * loaded yet or the char isn't in the set (caller should fall back to the char).\n */\nexport function emojiToDataUri(char: string): string | null {\n  if (!EMOJI_MAP) return null;\n  const key = (char || \"\").trim();\n  if (EMOJI_MAP[key]) return EMOJI_MAP[key];\n  // Tolerate a trailing variation selector mismatch (U+FE0F).\n  const stripped = key.replace(/️/g, \"\");\n  if (stripped !== key && EMOJI_MAP[stripped]) return EMOJI_MAP[stripped];\n  return null;\n}\n\n/** Subscribe to map-loaded so a component re-renders char → image when ready. */\nfunction useEmojiMapReady(): boolean {\n  const [, force] = React.useReducer((n: number) => n + 1, 0);\n  React.useEffect(() => {\n    if (EMOJI_MAP) return;\n    const fn = () => force();\n    subscribers.add(fn);\n    loadEmojiMap();\n    return () => {\n      subscribers.delete(fn);\n    };\n  }, []);\n  return EMOJI_MAP != null;\n}\n\nexport interface EmojiProps {\n  /** The emoji character(s), e.g. \"🎉\". */\n  char: string;\n  /** Box size in px — matches the prior glyph's font-size so layout is identical. */\n  size: number;\n  /** vertical-align for inline flow. Defaults to a glyph-like baseline nudge. */\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  /** Extra styles merged onto the <img> (or fallback <span>). */\n  style?: React.CSSProperties;\n}\n\n/**\n * Renders an emoji as an inline emoji-PNG <img> when available, else the raw\n * unicode char in a same-size box (so scale/opacity/transform are unchanged).\n */\nexport const Emoji: React.FC<EmojiProps> = ({ char, size, verticalAlign = \"-0.15em\", style }) => {\n  useEmojiMapReady();\n  const uri = emojiToDataUri(char);\n\n  if (!uri) {\n    // Fallback: raw char sized to the same box so layout/scale are preserved.\n    return (\n      <span\n        style={{\n          fontSize: size,\n          lineHeight: 1,\n          display: \"inline-block\",\n          ...style,\n        }}\n      >\n        {char}\n      </span>\n    );\n  }\n\n  return (\n    <img\n      src={uri}\n      alt={char}\n      width={size}\n      height={size}\n      style={{\n        width: size,\n        height: size,\n        objectFit: \"contain\",\n        display: \"inline-block\",\n        verticalAlign,\n        ...style,\n      }}\n    />\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/scene-background.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/scene-background.tsx",
      "content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n *   <SceneBackground\n *     style={style}\n *     progress={progress}\n *     sceneDuration={sceneDuration}\n *     width={width}\n *     height={height}\n *     mediaUrl={String(variables.mediaUrl || \"\")}\n *     mediaType={String(variables.mediaType || \"auto\")}\n *     seed={String(variables.texts || \"\")}\n *     isPlaying={isPlaying}\n *   />\n *   ... template's content layered on top\n *\n * Behavior:\n *   - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n *   - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n *     covers the gradient. Vignette + bottom-half darken give the content\n *     contrast against busy footage.\n *   - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n *     the brand gradient. First-class atmospheric mode.\n *   - When mediaUrl is empty / 404s / Pexels search returned nothing,\n *     gradient shows through cleanly (matches every other gradient-backed\n *     template).\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef } from \"react\";\nimport type { GlobalStyle, VariableField } from \"../video-config\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n  try {\n    const pathname = new URL(url).pathname.toLowerCase();\n    return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n  } catch {\n    const lower = url.toLowerCase();\n    return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n  }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n  center: \"center center\",\n  top: \"center top\",\n  bottom: \"center bottom\",\n  left: \"left center\",\n  right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n  return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n  return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n  id: \"vignette\" | \"full-wash\" | \"center-scrim\" | \"bottom-scrim\";\n  background: string;\n  style?: React.CSSProperties;\n}\n\n/** Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS filters. */\nexport function getMediaTreatmentLayers(value: string): MediaTreatmentLayer[] {\n  const treatment = resolveMediaTreatment(value);\n  const vignette: MediaTreatmentLayer = {\n    id: \"vignette\",\n    background:\n      treatment === \"subtle\"\n        ? \"radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.28) 100%)\"\n        : \"radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.55) 80%, rgba(0,0,0,0.75) 100%)\",\n  };\n  if (treatment === \"subtle\") return [vignette];\n\n  const cinematic: MediaTreatmentLayer[] = [\n    vignette,\n    {\n      id: \"center-scrim\",\n      background:\n        treatment === \"text-safe\"\n          ? \"radial-gradient(ellipse 90% 56% at 50% 50%, rgba(0,0,0,0.36) 0%, rgba(0,0,0,0.18) 55%, transparent 84%)\"\n          : \"radial-gradient(ellipse 85% 50% at 50% 50%, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.10) 50%, transparent 80%)\",\n    },\n    {\n      id: \"bottom-scrim\",\n      background:\n        treatment === \"text-safe\"\n          ? \"linear-gradient(to top, rgba(0,0,0,0.68) 0%, transparent 100%)\"\n          : \"linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)\",\n      style: { top: \"55%\" },\n    },\n  ];\n\n  if (treatment === \"text-safe\") {\n    cinematic.splice(1, 0, {\n      id: \"full-wash\",\n      background: \"rgba(0,0,0,0.30)\",\n    });\n  }\n  return cinematic;\n}\n\nexport function resolveMediaType(\n  mediaType: string,\n  mediaUrl: string,\n): ResolvedMediaType {\n  if (mediaType === \"gradient\") return \"gradient\";\n  if (mediaType === \"video\") return \"video\";\n  if (mediaType === \"photo\") return \"photo\";\n  // \"auto\" — detect from URL extension\n  return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\n/**\n * Standard schema fields any template can spread into its variableSchema\n * to enable media backgrounds. Keeps the field set + descriptions\n * consistent across templates.\n */\nexport const mediaBackgroundSchemaFields: Record<string, VariableField> = {\n  mediaUrl: {\n    type: \"media\",\n    label: \"Background media\",\n    description:\n      \"Optional photo or video URL behind this scene. When set, replaces the brand gradient.\",\n  },\n  mediaKeyword: {\n    type: \"string\",\n    label: \"Background search keyword\",\n    default: \"\",\n    description:\n      \"2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).\",\n  },\n  mediaType: {\n    type: \"enum\",\n    label: \"Background media type\",\n    default: \"auto\",\n    description:\n      \"auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.\",\n    options: [\"auto\", \"photo\", \"video\", \"gradient\"],\n  },\n  mediaPoster: {\n    type: \"string\",\n    label: \"Background poster image\",\n    default: \"\",\n    description:\n      \"Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame.\",\n  },\n  mediaPosition: {\n    type: \"enum\",\n    label: \"Background focal position\",\n    default: \"center\",\n    description:\n      \"Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.\",\n    options: [\"center\", \"top\", \"bottom\", \"left\", \"right\"],\n  },\n  mediaTreatment: {\n    type: \"enum\",\n    label: \"Background contrast treatment\",\n    default: \"cinematic\",\n    description:\n      \"subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.\",\n    options: [\"subtle\", \"cinematic\", \"text-safe\"],\n  },\n};\n\n/** Default values for the media-background fields. Spread into a template's defaults. */\nexport const mediaBackgroundDefaults: Record<string, unknown> = {\n  mediaUrl: \"\",\n  mediaKeyword: \"\",\n  mediaType: \"auto\",\n  mediaPoster: \"\",\n  mediaPosition: \"center\",\n  mediaTreatment: \"cinematic\",\n};\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n  return {\n    mediaUrl: String(variables.mediaUrl || \"\"),\n    mediaType: String(variables.mediaType || \"auto\"),\n    mediaPoster: String(variables.mediaPoster || \"\"),\n    mediaPosition: String(variables.mediaPosition || \"center\"),\n    mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n  };\n}\n\nexport interface SceneBackgroundProps {\n  style: GlobalStyle;\n  progress: number;\n  sceneDuration?: number;\n  width: number;\n  height: number;\n  mediaUrl?: string;\n  mediaType?: string;\n  /** Still image URL shown while the <video> backdrop decodes its first\n   *  frame. Without it the element renders transparent during the\n   *  ~50–400ms decode window and the gradient flashes through. */\n  mediaPoster?: string;\n  /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n  mediaPosition?: string;\n  /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n  mediaTreatment?: string;\n  /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n  backgroundEffect?: string;\n  /** Stable seed for the gradient breathing animation. Pass the scene's\n   *  text content (or any stable string) — it's hashed deterministically. */\n  seed?: number | string;\n  /** Pause video when preview is paused. Defaults to true (export path). */\n  isPlaying?: boolean;\n  beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n  style,\n  progress,\n  sceneDuration,\n  width: _width, // accepted for symmetry; not currently used in render\n  height: _height,\n  mediaUrl = \"\",\n  mediaType = \"auto\",\n  mediaPoster,\n  mediaPosition = \"center\",\n  mediaTreatment = \"cinematic\",\n  backgroundEffect,\n  seed,\n  isPlaying = true,\n  beatIntensity = 0,\n}) => {\n  void _width;\n  void _height;\n  const resolved = resolveMediaType(mediaType, mediaUrl);\n  const showMedia = resolved !== \"gradient\" && !!mediaUrl;\n  const resolvedPosition = resolveMediaPosition(mediaPosition);\n  const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n  const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment);\n\n  const gradSeed =\n    typeof seed === \"number\"\n      ? seed\n      : typeof seed === \"string\"\n        ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n        : 0;\n\n  const bgTransform = getBackgroundTransform(\n    backgroundEffect,\n    progress,\n    beatIntensity,\n  );\n\n  // Video playback control — same pause/seek logic bg-media used pre-extract.\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const lastProgress = useRef(progress);\n  const videoStarted = useRef(false);\n\n  useEffect(() => {\n    const vid = videoRef.current;\n    if (!vid) return;\n    if (!isPlaying) {\n      vid.pause();\n      videoStarted.current = false;\n      return;\n    }\n    const progressChanged = Math.abs(progress - lastProgress.current) > 0.001;\n    lastProgress.current = progress;\n    if (progressChanged && !videoStarted.current) {\n      vid.playbackRate = 1;\n      vid.currentTime = 0;\n      vid.play().catch(() => {});\n      videoStarted.current = true;\n    } else if (!progressChanged && videoStarted.current) {\n      vid.pause();\n      videoStarted.current = false;\n    }\n  }, [progress, isPlaying]);\n\n  // Release the decoder on unmount. Without this, iOS Safari keeps the\n  // video's decoder buffer alive after the React node is gone — each\n  // scene transition (or play/pause/play cycle that remounts the active\n  // scene) leaks one decoder, eventually crossing the renderer's memory\n  // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n  // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n  // Capture the ref at mount-time so the cleanup uses the same node we\n  // mounted (the ref's .current is stale by unmount).\n  useEffect(() => {\n    const vid = videoRef.current;\n    return () => {\n      if (!vid) return;\n      vid.pause();\n      vid.removeAttribute(\"src\");\n      vid.load();\n    };\n  }, []);\n\n  return (\n    <>\n      <BrandGradientOverlay\n        style={style}\n        progress={progress}\n        sceneDuration={sceneDuration}\n        seed={gradSeed}\n      />\n\n      {showMedia &&\n        (resolved === \"video\" ? (\n          <video\n            ref={videoRef}\n            src={mediaUrl}\n            // Poster paints during the decode window so the user sees the\n            // (still) first frame instead of a transparent <video> letting\n            // the brand gradient show through. Pexels returns a thumbnail\n            // image alongside each video; fillPexelsUrls stores it in\n            // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n            // below: on desktop the byte preloader makes decode fast, on\n            // mobile (where the preloader skips video pre-mounting to dodge\n            // the iOS Safari memory crash) the poster is the primary shield.\n            poster={mediaPoster || undefined}\n            muted\n            loop\n            playsInline\n            // preload=\"auto\" — without it, browsers default to \"metadata\":\n            // they only load the container/dimensions, not the byte stream\n            // needed to decode frames. The element then renders transparent\n            // until the first decoded frame arrives, letting the brand\n            // gradient flash through whenever a scene mid-playback transitions\n            // to a media backdrop. The parent preloader caches the bytes, but\n            // decoder state is per-element, so the active mount still has to\n            // decode the first frame; \"auto\" kicks that work off the instant\n            // the element mounts.\n            preload=\"auto\"\n            data-media-position={mediaPosition}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              width: \"100%\",\n              height: \"100%\",\n              objectFit: \"cover\",\n              objectPosition: resolvedPosition,\n              transform: bgTransform.transform,\n              transformOrigin: bgTransform.transformOrigin,\n            }}\n          />\n        ) : (\n          <div\n            data-media-position={mediaPosition}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              transform: bgTransform.transform,\n              transformOrigin: bgTransform.transformOrigin,\n              backgroundImage: `url(${mediaUrl})`,\n              backgroundSize: \"cover\",\n              backgroundPosition: resolvedPosition,\n            }}\n          />\n        ))}\n\n      {showMedia &&\n        treatmentLayers.map((layer) => (\n          <div\n            key={layer.id}\n            data-media-treatment={resolvedTreatment}\n            data-media-overlay={layer.id}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              background: layer.background,\n              pointerEvents: \"none\",\n              ...layer.style,\n            }}\n          />\n        ))}\n    </>\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/types.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/types.ts",
      "content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares what variables it needs (auto-shown as input fields in the Studio)\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { GlobalStyle, VariableField, SafeZone } from \"../video-config\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n  variables: Record<string, unknown>;\n  style: GlobalStyle;\n  /** 0→1 through the scene's duration */\n  progress: number;\n  /** 0→1 beat pulse intensity */\n  beatIntensity: number;\n  /** 1080 (portrait) or 1920 (landscape) */\n  width: number;\n  /** 1920 (portrait) or 1080 (landscape) */\n  height: number;\n  /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n  textArchetype?: string;\n  /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n  /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n  backgroundEffect?: string;\n  /** Platform-aware safe zone insets in pixels — use for text placement */\n  safeZone: SafeZone;\n  /** Scene duration in seconds — use for time-based (not progress-based) animations */\n  sceneDuration?: number;\n  /**\n   * Brand tokens already resolved from `style`. Built-in templates import\n   * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n   * so without this it has no way to reach the same values and ends up\n   * hardcoding white, black and shadows — the body then looks generic next to\n   * a frame that IS using the brand.\n   */\n  tokens?: ResolvedTokens;\n  /**\n   * True when the preview player is actively advancing progress; false when paused.\n   * Templates that play HTML5 <video> elements should pause them when this is false.\n   * Undefined (export capture path) is treated as true.\n   */\n  isPlaying?: boolean;\n}\n\n/**\n * What a template can DO inside a video. A template can serve more than\n * one job — `bigNumber` is `[\"claim\", \"proof\"]`; `media` is\n * `[\"atmosphere\", \"setup\"]`. Used by the chat composer to pick templates\n * by scene-job rather than by the (less useful) category bucket.\n *\n * - `setup` — opens the world, names the subject, frames the question\n * - `claim` — makes a falsifiable statement the rest of the video earns\n * - `proof` — backs a claim with a number, quote, mockup, code, or chart\n * - `atmosphere` — breathing room; ties scenes together visually\n * - `payoff` — punchline, reveal, or satisfying answer to an earlier setup\n * - `punctuation` — short energy beat that breaks pattern or lands a joke\n * - `ask` — pushes the viewer to the next action; closer-territory only\n */\nexport type TemplateJob = \"setup\" | \"claim\" | \"proof\" | \"atmosphere\" | \"payoff\" | \"punctuation\" | \"ask\";\n\n/**\n * The visual register a template lives in — what the *viewer* notices\n * before reading any copy. The chat's diversity rule reads this rather\n * than `category` because two `card-led` bodies look the same to the\n * viewer even when they're a `testimonial` and a `bigNumber`.\n *\n * - `motion-led` — animation IS the content (confetti, emoji rain, charts)\n * - `typography-led` — text fills the frame (bigNumber, ctaLogo, tripleStats)\n * - `device-led` — phone/browser/terminal frame is the focal element\n * - `card-led` — quote/feature/comparison cards\n * - `mockup-led` — full UI surface (chat thread, search results, app feed)\n */\nexport type TemplateRegister =\n  | \"motion-led\"\n  | \"typography-led\"\n  | \"device-led\"\n  | \"card-led\"\n  | \"mockup-led\";\n\n/**\n * A registered scene template.\n */\nexport interface SceneTemplate {\n  id: string;\n  /** Built-in templates leave these blank — DB owns them. Vibecoded\n   *  templates carry their own here since they have no DB row. */\n  label?: string;\n  description?: string;\n  category?: string | null;\n  /** What this template DOES inside a video. 1-3 jobs. See TemplateJob. */\n  jobs?: TemplateJob[];\n  /** The visual register the template lives in. See TemplateRegister. */\n  register?: TemplateRegister;\n  /** Semantic selection guidance: when this template is the right choice. */\n  useWhen?: string;\n  /** Thumbnail URL for visual picker (optional) */\n  thumbnail?: string;\n  /** If true, template consumes the video-level defaultTextArchetype */\n  usesGlobalTextEffect: boolean;\n  /** If true, template uses the video-level defaultTransition */\n  usesGlobalTransition: boolean;\n  /** If true, template uses the video-level defaultBackgroundEffect */\n  usesGlobalBackgroundEffect: boolean;\n  /**\n   * Spatial budget for text effects.\n   * - \"tight\" (default): text shares the frame with UI (mockup, chart). Expressive\n   *   effects are auto-clamped to contained equivalents to prevent clipping.\n   * - \"open\": text is the focal element (bg-* templates). All effects allowed.\n   */\n  textCanvas?: \"tight\" | \"open\";\n  /**\n   * Hard input gates — what this template needs from the user's input to fire\n   * legitimately. The AI uses these to filter out templates that would force\n   * it to invent content (e.g. \\`bigNumber\\` without a real number).\n   *\n   * Set to true when the template's value depends on input that the model\n   * can't fabricate honestly: a stat, a quote, an uploaded screenshot.\n   * Defaults to false (no constraint).\n   */\n  requiresStat?: boolean;\n  requiresQuote?: boolean;\n  requiresScreenshot?: boolean;\n  /** Template can be backed by Pexels stock footage (e.g. \\`media\\`). */\n  allowsStockMedia?: boolean;\n  /** Variable schema — Studio auto-generates inputs from this */\n  variableSchema: Record<string, VariableField>;\n  /** Default values for all variables */\n  defaultVariables: Record<string, unknown>;\n  /** Minimum scene duration in seconds */\n  minDuration?: number;\n  /** Recommended scene duration in seconds */\n  preferredDuration?: number;\n  /** The React component that renders this template */\n  component: React.FC<SceneTemplateProps>;\n}\n\n/**\n * Serializable template metadata (no component) — for edge functions, API, MCP.\n */\nexport interface SceneTemplateMetadata {\n  id: string;\n  usesGlobalTextEffect: boolean;\n  usesGlobalTransition: boolean;\n  usesGlobalBackgroundEffect: boolean;\n  textCanvas?: \"tight\" | \"open\";\n  /** Hard input gates — see SceneTemplate.requiresStat etc. */\n  requiresStat?: boolean;\n  requiresQuote?: boolean;\n  requiresScreenshot?: boolean;\n  allowsStockMedia?: boolean;\n  /** What this template DOES inside a video. 1-3 jobs. See TemplateJob. */\n  jobs?: TemplateJob[];\n  /** The visual register the template lives in. See TemplateRegister. */\n  register?: TemplateRegister;\n  /** Semantic selection guidance: when this template is the right choice. */\n  useWhen?: string;\n  variableSchema: Record<string, VariableField>;\n  defaultVariables: Record<string, unknown>;\n  minDuration?: number;\n  preferredDuration?: number;\n}\n"
    }
  ],
  "meta": {
    "vanillasky": {
      "layer": "template",
      "category": "social",
      "tier": "free",
      "register": "typography-led",
      "jobs": [
        "claim",
        "proof"
      ],
      "useWhen": "Real numeric achievement explicitly in the input: users, revenue, years, installs, customers, stars, or adoption count. Use exact targetNumber; never for generic releases, versions, 'free', or proof without an achievement count.",
      "textCanvas": "open",
      "minDuration": 2.5,
      "preferredDuration": 4,
      "gates": {
        "requiresStat": true
      },
      "allowsStockMedia": true,
      "variableSchema": {
        "label": {
          "type": "string",
          "label": "Label",
          "default": "Followers",
          "required": true,
          "description": "Label above the number (e.g. Followers, Subscribers, Downloads)"
        },
        "targetNumber": {
          "type": "number",
          "label": "Target number",
          "default": 10000,
          "required": true,
          "description": "The milestone number to reach"
        },
        "startNumber": {
          "type": "number",
          "label": "Start number",
          "default": "",
          "description": "Number the counter starts rolling from. Leave empty to auto-calculate (~98% of target)."
        },
        "badgeText": {
          "type": "string",
          "label": "Badge text",
          "default": "10K Followers!",
          "description": "Celebration badge text that pops in at milestone"
        },
        "badgeEmoji": {
          "type": "string",
          "label": "Badge emoji",
          "default": "🎉",
          "description": "Emoji shown in the celebration badge"
        },
        "badgeColor": {
          "type": "color",
          "label": "Badge color",
          "default": "#facc15",
          "description": "Background color of the celebration badge"
        },
        "mediaUrl": {
          "type": "media",
          "label": "Background media",
          "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient."
        },
        "mediaKeyword": {
          "type": "string",
          "label": "Background search keyword",
          "default": "",
          "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl)."
        },
        "mediaType": {
          "type": "enum",
          "label": "Background media type",
          "default": "auto",
          "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
          "options": [
            "auto",
            "photo",
            "video",
            "gradient"
          ]
        },
        "mediaPoster": {
          "type": "string",
          "label": "Background poster image",
          "default": "",
          "description": "Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame."
        },
        "mediaPosition": {
          "type": "enum",
          "label": "Background focal position",
          "default": "center",
          "description": "Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.",
          "options": [
            "center",
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "mediaTreatment": {
          "type": "enum",
          "label": "Background contrast treatment",
          "default": "cinematic",
          "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
          "options": [
            "subtle",
            "cinematic",
            "text-safe"
          ]
        }
      },
      "defaultVariables": {
        "label": "Followers",
        "targetNumber": 10000,
        "startNumber": "",
        "badgeText": "10K Followers!",
        "badgeEmoji": "🎉",
        "badgeColor": "#facc15",
        "mediaUrl": "",
        "mediaKeyword": "",
        "mediaType": "auto",
        "mediaPoster": "",
        "mediaPosition": "center",
        "mediaTreatment": "cinematic"
      }
    }
  }
}
