{
  "name": "b-roll-stack",
  "type": "registry:block",
  "description": "Deck of supporting shots dealt one at a time: each rides to the front with its label while the last slides back into the stack",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts",
    "@remotion/media"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "media-utils",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/b-roll-stack/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { Video } from \"@remotion/media\";\nimport { Img, interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { getSafeAreaPadding } from \"@/remotion/lib/layout\";\nimport { EASING } from \"@/remotion/lib/motion-tokens\";\nimport {\n  getMediaObjectFitStyle,\n  isVideoSource,\n  type MediaFit,\n} from \"@/remotion/lib/media-utils\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type BRollItem = {\n  src: string;\n  /** Label on the card while it is at the front. */\n  title?: string;\n  /** Second line under the label. */\n  caption?: string;\n  fit?: MediaFit;\n  backgroundColor?: string;\n};\n\nexport type BRollStackProps = {\n  items?: BRollItem[];\n  /** Headline beside the deck. */\n  title?: string;\n  /** Small label above the headline. */\n  kicker?: string;\n  /** Supporting line under the headline. */\n  caption?: string;\n  /** Seconds each shot holds at the front before the deck advances. */\n  holdSeconds?: number;\n  /** Aspect the cards are cut to. */\n  aspect?: number;\n  backgroundColor?: string;\n  accentColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\n/** Beat plan in seconds. */\nconst T = {\n  header: 0,\n  /** Deck lands after the headline. */\n  deck: 0.28,\n  /** First advance, measured from the deck landing. */\n  firstHold: 1.35,\n  /** Time one card takes to ride to the front. */\n  advance: 0.55,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/** How a card sits at a given depth in the deck — 0 is the front. */\nconst placement = (depth: number, u: number) => ({\n  x: depth * 34 * u,\n  y: depth * -22 * u,\n  scale: 1 - depth * 0.07,\n  rotate: depth * 1.6,\n});\n\n/**\n * A deck of supporting shots being dealt rather than three cards fanned out and\n * held: each shot rides to the front in turn with its own label, the one it\n * replaces slides back into the stack, and the deck keeps cycling for as long\n * as the scene runs.\n */\nexport const BRollStack: React.FC<BRollStackProps> = ({\n  items = [],\n  title,\n  kicker,\n  caption,\n  holdSeconds = 1.35,\n  aspect = 16 / 9,\n  backgroundColor,\n  accentColor = \"#E8B86D\",\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const safe = getSafeAreaPadding({ width, height });\n\n  const now = (frame / fps) * speed;\n  const at = (seconds: number) => (seconds * fps) / speed;\n  const ease = (from: number, to: number, easing = EASING.enter) =>\n    interpolate(frame, [at(from), at(to)], [0, 1], { easing, ...clamp });\n\n  const stage = {\n    x: safe.paddingLeft,\n    y: safe.paddingTop,\n    w: width - safe.paddingLeft - safe.paddingRight,\n    h: height - safe.paddingTop - safe.paddingBottom,\n  };\n  const portrait = height > width;\n  // The reference stage turns with the composition — sizing a 9:16 frame off a\n  // landscape reference leaves the headline tiny.\n  const u = portrait\n    ? Math.min(stage.w / 620, stage.h / 1120)\n    : Math.min(stage.w / 1120, stage.h / 620);\n  const count = items.length;\n\n  const headerIn = ease(T.header, T.header + 0.5);\n  const deckIn = ease(T.deck, T.deck + 0.55, EASING.editorial);\n\n  // Continuous front index: it holds on whole numbers and eases between them,\n  // so every card can be placed from this one number instead of per-card state.\n  const cycle = holdSeconds + T.advance;\n  const elapsed = Math.max(now - (T.deck + T.firstHold - holdSeconds), 0);\n  const step = Math.floor(elapsed / cycle);\n  const advance = interpolate(\n    elapsed - step * cycle,\n    [holdSeconds, cycle],\n    [0, 1],\n    { easing: EASING.editorial, ...clamp },\n  );\n  const front = count > 0 ? step + advance : 0;\n\n  const headerW = portrait ? stage.w : stage.w * 0.36;\n  const deckBox = portrait\n    ? { x: 0, y: stage.h * 0.24, w: stage.w, h: stage.h * 0.5 }\n    : { x: headerW + 40 * u, y: 0, w: stage.w - headerW - 40 * u, h: stage.h };\n  // Room is left for the deepest visible card to sit up and to the right.\n  const cardW = Math.min(deckBox.w - 76 * u, (deckBox.h - 70 * u) * aspect);\n  const cardH = cardW / aspect;\n  const cardX = deckBox.x + (deckBox.w - cardW) / 2 - 34 * u;\n  const cardY = deckBox.y + (deckBox.h - cardH) / 2 + (portrait ? 0 : 22 * u);\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: backgroundColor ?? palette.page,\n        fontFamily,\n        position: \"relative\",\n        overflow: \"hidden\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(ellipse 60% 55% at ${\n            portrait ? 50 : 70\n          }% 45%, ${accentColor}14, transparent 72%)`,\n        }}\n      />\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: stage.x,\n          top: stage.y,\n          width: stage.w,\n          height: stage.h,\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: portrait ? 0 : \"50%\",\n            width: headerW,\n            transform: portrait ? undefined : \"translateY(-50%)\",\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: 12 * u,\n          }}\n        >\n          {kicker ? (\n            <div\n              style={{\n                color: accentColor,\n                fontSize: 20 * u,\n                fontWeight: 600,\n                letterSpacing: \"0.12em\",\n                textTransform: \"uppercase\",\n                opacity: headerIn,\n                transform: `translateY(${(1 - headerIn) * 10 * u}px)`,\n              }}\n            >\n              {kicker}\n            </div>\n          ) : null}\n          {title ? (\n            <div\n              style={{\n                color: palette.fg,\n                fontSize: 50 * u,\n                fontWeight: 700,\n                lineHeight: 1.08,\n                letterSpacing: \"-0.02em\",\n                opacity: headerIn,\n                transform: `translateY(${(1 - headerIn) * 16 * u}px)`,\n              }}\n            >\n              {title}\n            </div>\n          ) : null}\n          {caption ? (\n            <div\n              style={{\n                color: palette.dim,\n                fontSize: 23 * u,\n                lineHeight: 1.4,\n                opacity: ease(T.header + 0.18, T.header + 0.62),\n              }}\n            >\n              {caption}\n            </div>\n          ) : null}\n          {count > 1 ? (\n            <div\n              style={{\n                marginTop: 6 * u,\n                color: palette.faint,\n                fontSize: 20 * u,\n                fontVariantNumeric: \"tabular-nums\",\n                opacity: deckIn,\n              }}\n            >\n              {String((Math.round(front) % count) + 1).padStart(2, \"0\")} /{\" \"}\n              {String(count).padStart(2, \"0\")}\n            </div>\n          ) : null}\n        </div>\n\n        {items.map((item, index) => {\n          // Depth in the deck, wrapping so the card that just left the front\n          // reappears at the back rather than travelling across the frame.\n          const depth =\n            count > 0 ? (((index - front) % count) + count) % count : 0;\n          if (depth > 2.6) {\n            return null;\n          }\n          const spot = placement(depth, u);\n          const isFront = depth < 0.5;\n          // The deepest card fades in as it takes its place, so the wrap is\n          // never seen.\n          const fade = interpolate(depth, [2, 2.6], [1, 0], clamp);\n\n          return (\n            <div\n              key={`${item.src}-${index}`}\n              style={{\n                position: \"absolute\",\n                left: cardX,\n                top: cardY,\n                width: cardW,\n                height: cardH,\n                borderRadius: 20 * u,\n                overflow: \"hidden\",\n                background: item.backgroundColor ?? palette.window,\n                border: `1px solid ${\n                  isFront ? `${accentColor}66` : palette.border\n                }`,\n                boxShadow: `0 ${(14 + (2 - depth) * 12) * u}px ${\n                  (40 + (2 - depth) * 26) * u\n                }px ${palette.shadow}`,\n                opacity: deckIn * fade,\n                transform: `translate(${spot.x}px, ${\n                  spot.y + (1 - deckIn) * 40 * u\n                }px) scale(${spot.scale}) rotate(${spot.rotate}deg)`,\n                zIndex: Math.round(100 - depth * 10),\n              }}\n            >\n              {isVideoSource(item.src) ? (\n                <Video\n                  src={item.src}\n                  muted\n                  loop\n                  style={getMediaObjectFitStyle(item.fit ?? \"cover\")}\n                />\n              ) : (\n                <Img\n                  src={item.src}\n                  style={getMediaObjectFitStyle(item.fit ?? \"cover\")}\n                />\n              )}\n\n              {/* Cards behind the front one are pushed down so the front card\n                  keeps the eye */}\n              <div\n                style={{\n                  position: \"absolute\",\n                  inset: 0,\n                  background: palette.page,\n                  opacity: interpolate(depth, [0, 1], [0, 0.45], clamp),\n                }}\n              />\n\n              {item.title || item.caption ? (\n                <div\n                  style={{\n                    position: \"absolute\",\n                    left: 0,\n                    right: 0,\n                    bottom: 0,\n                    paddingTop: 64 * u,\n                    paddingLeft: 22 * u,\n                    paddingRight: 22 * u,\n                    paddingBottom: 18 * u,\n                    // Tall enough to sit the label on its own band — a short\n                    // scrim leaves it fighting whatever the shot has at its foot.\n                    background: `linear-gradient(180deg, transparent, ${palette.page}CC 44%, ${palette.page}F5)`,\n                    opacity: interpolate(depth, [0, 0.6], [1, 0], clamp),\n                  }}\n                >\n                  {item.title ? (\n                    <div\n                      style={{\n                        color: \"#F5F6F8\",\n                        fontSize: 26 * u,\n                        fontWeight: 600,\n                        letterSpacing: \"-0.01em\",\n                      }}\n                    >\n                      {item.title}\n                    </div>\n                  ) : null}\n                  {item.caption ? (\n                    <div\n                      style={{\n                        marginTop: 4 * u,\n                        color: accentColor,\n                        fontSize: 19 * u,\n                        fontWeight: 500,\n                      }}\n                    >\n                      {item.caption}\n                    </div>\n                  ) : null}\n                </div>\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "media",
    "tier": "core"
  }
}