{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reviewStack",
  "type": "registry:block",
  "title": "Review Stack",
  "description": "Two or more real attributed reviews, ratings, or quotes that should appear as social proof together. Requires real quote/source content.",
  "dependencies": [
    "react"
  ],
  "registryDependencies": [
    "@vanillasky/backgrounds",
    "@vanillasky/motion",
    "@vanillasky/theme",
    "@vanillasky/typography",
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/scene-templates/social-review-stack.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/social-review-stack.tsx",
      "content": "/**\n * social-review-stack — stacked review cards with staggered spring entrance.\n *\n * Converted from Remotion AppStoreReviews. Three review cards stacked with\n * slight rotations, each sliding in with a spring delay. Gold star ratings,\n * dark cards on dark background.\n *\n * Block structure (docs/blocks.md):\n *   background — SceneBackground (brand gradient / Pexels media + scrims)\n *   hero       — ReviewStack primitive (the card stack IS the scene; no\n *                separate caption slot — review titles carry the text)\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 { ReviewStack } from \"../primitives/social/ReviewStack\";\n\n/** Build reviews array from individual variable fields */\nfunction buildReviews(variables: Record<string, unknown>): { title: string; body: string; author: string }[] {\n  const reviews: { title: string; body: string; author: string }[] = [];\n  for (let i = 1; i <= 3; i++) {\n    const title = String(variables[`review${i}Title`] || \"\");\n    if (!title) continue;\n    reviews.push({\n      title,\n      body: String(variables[`review${i}Body`] || \"\"),\n      author: String(variables[`review${i}Author`] || \"\"),\n    });\n  }\n  return reviews;\n}\n\nexport const socialReviewStackSchema: Record<string, VariableField> = {\n  review1Title: {\n    type: \"string\",\n    label: \"Review 1 — Title\",\n    default: \"Life changing app\",\n    required: true,\n    description: \"Headline of the first review\",\n  },\n  review1Body: {\n    type: \"string\",\n    label: \"Review 1 — Body\",\n    default: \"Been using this for a month and can't imagine going back\",\n    description: \"Body text of the first review\",\n  },\n  review1Author: {\n    type: \"string\",\n    label: \"Review 1 — Author\",\n    default: \"Sarah M.\",\n    description: \"Author name for the first review\",\n  },\n  review2Title: {\n    type: \"string\",\n    label: \"Review 2 — Title\",\n    default: \"Best in class\",\n    required: true,\n    description: \"Headline of the second review\",\n  },\n  review2Body: {\n    type: \"string\",\n    label: \"Review 2 — Body\",\n    default: \"Finally an app that just works. No bloat, no nonsense.\",\n    description: \"Body text of the second review\",\n  },\n  review2Author: {\n    type: \"string\",\n    label: \"Review 2 — Author\",\n    default: \"Mike R.\",\n    description: \"Author name for the second review\",\n  },\n  review3Title: {\n    type: \"string\",\n    label: \"Review 3 — Title\",\n    default: \"Exceeded expectations\",\n    required: true,\n    description: \"Headline of the third review\",\n  },\n  review3Body: {\n    type: \"string\",\n    label: \"Review 3 — Body\",\n    default: \"Worth every penny. The team clearly cares about quality.\",\n    description: \"Body text of the third review\",\n  },\n  review3Author: {\n    type: \"string\",\n    label: \"Review 3 — Author\",\n    default: \"Alex K.\",\n    description: \"Author name for the third review\",\n  },\n  starColor: {\n    type: \"color\",\n    label: \"Star color\",\n    default: \"#facc15\",\n    description: \"Color for the star ratings\",\n  },\n  ...mediaBackgroundSchemaFields,\n};\n\nexport const socialReviewStackDefaults: Record<string, unknown> = {\n  review1Title: \"Life changing app\",\n  review1Body: \"Been using this for a month and can't imagine going back\",\n  review1Author: \"Sarah M.\",\n  review2Title: \"Best in class\",\n  review2Body: \"Finally an app that just works. No bloat, no nonsense.\",\n  review2Author: \"Mike R.\",\n  review3Title: \"Exceeded expectations\",\n  review3Body: \"Worth every penny. The team clearly cares about quality.\",\n  review3Author: \"Alex K.\",\n  starColor: \"#facc15\",\n  ...mediaBackgroundDefaults,\n};\n\nexport const SocialReviewStackTemplate: React.FC<SceneTemplateProps> = ({\n  variables,\n  style,\n  progress,\n  beatIntensity,\n  width,\n  height,\n  sceneDuration,\n  isPlaying = true,\n}) => {\n  const { font, explicit } = resolveTokens(style);\n  const starColor = String(variables.starColor || \"\") || \"#facc15\";\n\n  const reviews = buildReviews(variables);\n  const gradSeed = String(variables.review1Title || \"review\").split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\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        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      {/* [slot: background] Gradient / media backdrop */}\n      <SceneBackground\n        style={style}\n        progress={progress}\n        sceneDuration={sceneDuration}\n        width={width}\n        height={height}\n        {...getMediaBackgroundProps(variables)}\n        seed={gradSeed}\n        isPlaying={isPlaying}\n        beatIntensity={beatIntensity}\n      />\n      {/* [slot: hero] Stacked review cards — shared primitive */}\n      <ReviewStack\n        progress={progress}\n        width={width}\n        height={height}\n        reviews={reviews}\n        starColor={starColor}\n        surfaceElevated={explicit.surface_elevated}\n        beatIntensity={beatIntensity}\n      />\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/primitives/social/ReviewStack.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/social/ReviewStack.tsx",
      "content": "/**\n * ReviewStack — stacked review cards with staggered spring entrance.\n *\n * The primitive twin of `social-review-stack.tsx`'s card stack — and since\n * the ONE implementation: the template composes this\n * component, so template fixes reach vibecoded scenes and vice versa.\n * The template's card anatomy is the tuned source of truth:\n *   - 5-star row (filled stars in starColor, unfilled at 15% white)\n *   - bold white title, muted slate body, dim author line\n *   - per-card delayed spring entrance with rotation + stack offset\n *\n * It does NOT own the SceneBackground (gradient or media) — that\n * stays in the scene composer so the same stack can render over\n * brand gradient, Pexels photo, or a custom backdrop.\n *\n * Props:\n *  - progress   — scene progress 0..1 (drives the per-card spring)\n *  - width      — frame width\n *  - height     — frame height\n *  - reviews    — array of `{ stars?, title?, quote?, body?, author? }`.\n *                 `title` is the bold headline; `quote` is a legacy alias\n *                 for it (older vibecoded scenes passed `{stars, quote,\n *                 author}` — the quote stays the card's primary line).\n *                 `body` is the muted supporting copy. Stars clamped 0..5,\n *                 default 5 (all filled — the template look).\n *  - starColor  — color for filled stars (default gold `#facc15`)\n *  - surfaceElevated — explicit brand-kit card surface; falls back to the\n *                 template's tuned near-black card fill\n *  - accent     — brand accent (unused today, kept for parity)\n *  - beatIntensity — optional 0..1 audio reactivity (subtle scale pop)\n */\n\nimport * as React from \"react\";\nimport {\n  interpolate,\n  spring,\n  SPRING_SNAPPY,\n} from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { lighten } from \"../../theme\";\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\" as const,\n  extrapolateRight: \"clamp\" as const,\n};\n\n// Fixed rotations for stacked look — deterministic per index\nconst ROTATIONS = [-3, 0, 3, -2, 2];\n\nexport interface ReviewStackEntry {\n  /** 0–5 filled stars. Default 5 (all filled). */\n  stars?: number;\n  /** Bold headline line of the card. */\n  title?: string;\n  /** Legacy alias for `title` — kept so older vibecoded scenes that pass\n   *  `{stars, quote, author}` keep their quote as the card's primary line. */\n  quote?: string;\n  /** Muted supporting copy below the title. */\n  body?: string;\n  author?: string;\n}\n\nexport interface ReviewStackProps {\n  progress: number;\n  width: number;\n  height: number;\n  reviews: ReviewStackEntry[];\n  starColor?: string;\n  /** Explicit brand-kit elevated surface for the card fill. */\n  surfaceElevated?: string;\n  accent?: string;\n  beatIntensity?: number;\n}\n\nexport const ReviewStack: React.FC<ReviewStackProps> = ({\n  progress,\n  width,\n  height,\n  reviews,\n  starColor = \"#facc15\",\n  surfaceElevated,\n  beatIntensity = 0,\n}) => {\n  const dim = Math.min(width, height);\n  const s = dim / 1080;\n\n  const cardBg = surfaceElevated || lighten(\"#0a0a0f\", 0.08);\n  const borderColor = lighten(\"#0a0a0f\", 0.15);\n\n  const cardWidth = dim * 0.72;\n  const cardPad = dim * 0.04;\n\n  const beatScale = 1 + beatIntensity * 0.01;\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      {reviews.map((review, i) => {\n        // Mirror source timing: stagger 0.19 progress per card, 0.24 settle\n        const cardStart = i * 0.19;\n        const cardEnd = cardStart + 0.24;\n\n        const cardP = spring(\n          interpolate(progress, [cardStart, cardEnd], [0, 1], CLAMP),\n          SPRING_SNAPPY,\n        );\n\n        const cardScale = interpolate(cardP, [0, 1], [0.7, 1]);\n        const cardOpacity = cardP;\n        const cardY = interpolate(cardP, [0, 1], [60 * s, 0]);\n        const rotation = ROTATIONS[i % ROTATIONS.length];\n\n        // Stack offset — each card slightly below the previous\n        const stackOffset = i * dim * 0.014;\n\n        const stars = review.stars == null\n          ? 5\n          : Math.max(0, Math.min(5, Math.round(review.stars)));\n        const title = stripPipe(review.title || review.quote || \"\");\n        const body = stripPipe(review.body || \"\");\n        const author = stripPipe(review.author || \"\");\n\n        return (\n          <div\n            key={i}\n            style={{\n              position: \"absolute\",\n              width: cardWidth,\n              padding: cardPad,\n              backgroundColor: cardBg,\n              borderRadius: dim * 0.018,\n              border: `1px solid ${borderColor}`,\n              boxShadow: `0 ${dim * 0.01}px ${dim * 0.04}px rgba(0,0,0,0.4)`,\n              transform: `rotate(${rotation}deg) translateY(${cardY + stackOffset}px) scale(${cardScale * beatScale})`,\n              opacity: cardOpacity,\n              zIndex: i,\n              top: \"50%\",\n              left: \"50%\",\n              marginLeft: -(cardWidth / 2),\n              marginTop: -(dim * 0.12),\n            }}\n          >\n            {/* Stars */}\n            <div style={{ display: \"flex\", gap: 3 * s, marginBottom: dim * 0.018 }}>\n              {Array.from({ length: 5 }).map((_, si) => (\n                <span\n                  key={si}\n                  style={{\n                    color: si < stars ? starColor : \"rgba(255,255,255,0.15)\",\n                    fontSize: dim * 0.038,\n                    lineHeight: 1,\n                  }}\n                >\n                  ★\n                </span>\n              ))}\n            </div>\n\n            {/* Title */}\n            <div\n              style={{\n                fontSize: dim * 0.042,\n                fontWeight: 700,\n                color: \"#ffffff\",\n                marginBottom: dim * 0.014,\n                lineHeight: 1.3,\n              }}\n            >\n              {title}\n            </div>\n\n            {/* Body */}\n            {body && (\n              <div\n                style={{\n                  fontSize: dim * 0.032,\n                  color: \"#94a3b8\",\n                  lineHeight: 1.5,\n                  marginBottom: dim * 0.022,\n                }}\n              >\n                {body}\n              </div>\n            )}\n\n            {/* Author */}\n            {author && (\n              <div\n                style={{\n                  fontSize: dim * 0.028,\n                  color: \"#475569\",\n                  fontWeight: 500,\n                }}\n              >\n                {author}\n              </div>\n            )}\n          </div>\n        );\n      })}\n    </div>\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": "card-led",
      "jobs": [
        "proof"
      ],
      "useWhen": "Two or more real attributed reviews, ratings, or quotes that should appear as social proof together. Requires real quote/source content.",
      "textCanvas": "open",
      "minDuration": 2.5,
      "preferredDuration": 4,
      "gates": {
        "requiresQuote": true
      },
      "allowsStockMedia": true,
      "variableSchema": {
        "review1Title": {
          "type": "string",
          "label": "Review 1 — Title",
          "default": "Life changing app",
          "required": true,
          "description": "Headline of the first review"
        },
        "review1Body": {
          "type": "string",
          "label": "Review 1 — Body",
          "default": "Been using this for a month and can't imagine going back",
          "description": "Body text of the first review"
        },
        "review1Author": {
          "type": "string",
          "label": "Review 1 — Author",
          "default": "Sarah M.",
          "description": "Author name for the first review"
        },
        "review2Title": {
          "type": "string",
          "label": "Review 2 — Title",
          "default": "Best in class",
          "required": true,
          "description": "Headline of the second review"
        },
        "review2Body": {
          "type": "string",
          "label": "Review 2 — Body",
          "default": "Finally an app that just works. No bloat, no nonsense.",
          "description": "Body text of the second review"
        },
        "review2Author": {
          "type": "string",
          "label": "Review 2 — Author",
          "default": "Mike R.",
          "description": "Author name for the second review"
        },
        "review3Title": {
          "type": "string",
          "label": "Review 3 — Title",
          "default": "Exceeded expectations",
          "required": true,
          "description": "Headline of the third review"
        },
        "review3Body": {
          "type": "string",
          "label": "Review 3 — Body",
          "default": "Worth every penny. The team clearly cares about quality.",
          "description": "Body text of the third review"
        },
        "review3Author": {
          "type": "string",
          "label": "Review 3 — Author",
          "default": "Alex K.",
          "description": "Author name for the third review"
        },
        "starColor": {
          "type": "color",
          "label": "Star color",
          "default": "#facc15",
          "description": "Color for the star ratings"
        },
        "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": {
        "review1Title": "Life changing app",
        "review1Body": "Been using this for a month and can't imagine going back",
        "review1Author": "Sarah M.",
        "review2Title": "Best in class",
        "review2Body": "Finally an app that just works. No bloat, no nonsense.",
        "review2Author": "Mike R.",
        "review3Title": "Exceeded expectations",
        "review3Body": "Worth every penny. The team clearly cares about quality.",
        "review3Author": "Alex K.",
        "starColor": "#facc15",
        "mediaUrl": "",
        "mediaKeyword": "",
        "mediaType": "auto",
        "mediaPoster": "",
        "mediaPosition": "center",
        "mediaTreatment": "cinematic"
      }
    }
  }
}
