{
  "name": "caption-scene",
  "type": "registry:block",
  "description": "Lower-third TikTok-style captions synced to audio",
  "dependencies": [
    "remotion",
    "@remotion/captions"
  ],
  "registryDependencies": [
    "caption-highlight",
    "caption-utils",
    "layout",
    "karaoke-captions",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/caption-scene/index.tsx",
      "type": "registry:block",
      "content": "import type { Caption } from \"@remotion/captions\";\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { useMemo } from \"react\";\nimport {\n  AbsoluteFill,\n  interpolate,\n  Loop,\n  Sequence,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { CaptionHighlight } from \"@/remotion/primitives/caption-highlight\";\nimport { KaraokeCaptions } from \"@/remotion/primitives/karaoke-captions\";\nimport {\n  DEFAULT_CAPTION_PAGE_MS,\n  getPageSequenceTiming,\n  groupCaptionsIntoPages,\n} from \"@/remotion/lib/caption-utils\";\nimport {\n  getSafeAreaPadding,\n  scaleFont,\n  type SafeAreaPadding,\n} from \"@/remotion/lib/layout\";\nimport { DURATION, EASING } from \"@/remotion/lib/motion-tokens\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"500\", \"600\", \"700\", \"800\"],\n  subsets: [\"latin\"],\n});\n\nexport type CaptionPlacement = \"lower-third\" | \"center\";\n\nexport type CaptionSceneMode =\n  | \"highlight\"\n  | \"karaoke-scale\"\n  | \"karaoke-underline\";\n\nexport type CaptionSceneProps = {\n  captions: Caption[];\n  combineTokensWithinMilliseconds?: number;\n  activeColor?: string;\n  inactiveColor?: string;\n  backgroundColor?: string;\n  placement?: CaptionPlacement;\n  mode?: CaptionSceneMode;\n  label?: string;\n  /**\n   * How long this scene actually plays for. Pass the enclosing `Sequence`'s\n   * `durationInFrames` when the scene is nested inside one (it is shorter\n   * than `useVideoConfig().durationInFrames`, which always reports the full\n   * composition). Falls back to the composition duration when omitted.\n   */\n  durationInFrames?: number;\n};\n\nconst COLORS = {\n  active: \"#ff6b00\",\n  ink: \"#111111\",\n  paper: \"#f5f4f2\",\n  plate: \"rgba(245, 244, 242, 0.92)\",\n  plateDark: \"rgba(17, 17, 17, 0.78)\",\n  border: \"rgba(17, 17, 17, 0.12)\",\n  muted: \"rgba(17, 17, 17, 0.52)\",\n} as const;\n\ntype CaptionPageProps = {\n  page: ReturnType<typeof groupCaptionsIntoPages>[number];\n  mode: CaptionSceneMode;\n  activeColor: string;\n  inactiveColor: string;\n  fontSize: number;\n  placement: CaptionPlacement;\n  captionZoneWidth: number;\n  safeArea: SafeAreaPadding;\n  bottomSlot: number;\n  label: string;\n};\n\nfunction CaptionPage({\n  page,\n  mode,\n  activeColor,\n  inactiveColor,\n  fontSize,\n  placement,\n  captionZoneWidth,\n  safeArea,\n  bottomSlot,\n  label,\n}: CaptionPageProps) {\n  const frame = useCurrentFrame();\n  const { fps, width } = useVideoConfig();\n  const enter = interpolate(frame, [0, DURATION.fast], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: EASING.enter,\n  });\n  const pageProgress = interpolate(\n    frame,\n    [0, Math.max(1, (page.durationMs / 1000) * fps)],\n    [0, 1],\n    {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    },\n  );\n\n  const content =\n    mode === \"karaoke-scale\" ? (\n      <KaraokeCaptions\n        page={page}\n        frame={frame}\n        activeColor={activeColor}\n        completedColor={inactiveColor}\n        inactiveColor=\"rgba(255,255,255,0.44)\"\n        fontSize={fontSize}\n        mode=\"scale\"\n      />\n    ) : mode === \"karaoke-underline\" ? (\n      <KaraokeCaptions\n        page={page}\n        frame={frame}\n        activeColor={activeColor}\n        completedColor={inactiveColor}\n        inactiveColor=\"rgba(255,255,255,0.44)\"\n        fontSize={fontSize}\n        mode=\"underline\"\n      />\n    ) : (\n      <CaptionHighlight\n        page={page}\n        frame={frame}\n        activeColor={activeColor}\n        inactiveColor={inactiveColor}\n        fontSize={fontSize}\n        textAlign={placement === \"center\" ? \"center\" : \"left\"}\n      />\n    );\n\n  const platePaddingY = scaleFont(18, width);\n  const platePaddingX = scaleFont(24, width);\n  const progressHeight = Math.max(3, scaleFont(3, width));\n  const labelSize = Math.max(12, scaleFont(14, width));\n\n  const plate = (\n    <div\n      style={{\n        width: \"100%\",\n        maxWidth: captionZoneWidth,\n        border: `1px solid rgba(255,255,255,0.18)`,\n        borderRadius: scaleFont(10, width),\n        background: COLORS.plateDark,\n        color: inactiveColor,\n        overflow: \"hidden\",\n        boxShadow: \"0 18px 50px rgba(0, 0, 0, 0.26)\",\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: scaleFont(10, width),\n          padding: `${platePaddingY}px ${platePaddingX}px ${scaleFont(10, width)}px`,\n        }}\n      >\n        <div\n          style={{\n            width: scaleFont(7, width),\n            height: scaleFont(7, width),\n            borderRadius: 999,\n            background: activeColor,\n          }}\n        />\n        <div\n          style={{\n            color: \"rgba(255,255,255,0.64)\",\n            fontSize: labelSize,\n            fontWeight: 700,\n            lineHeight: 1,\n          }}\n        >\n          {label}\n        </div>\n      </div>\n      <div\n        style={{\n          padding: `0 ${platePaddingX}px ${platePaddingY}px`,\n        }}\n      >\n        {content}\n      </div>\n      <div\n        style={{\n          height: progressHeight,\n          background: \"rgba(255,255,255,0.14)\",\n        }}\n      >\n        <div\n          style={{\n            width: `${pageProgress * 100}%`,\n            height: \"100%\",\n            background: activeColor,\n          }}\n        />\n      </div>\n    </div>\n  );\n\n  const centered = placement === \"center\";\n\n  // Content lives in a flex slot inside the safe area — never raw top/left offsets,\n  // so long copy pushes the plate instead of overflowing the frame.\n  return (\n    <AbsoluteFill\n      style={{\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: centered ? \"center\" : \"flex-end\",\n        paddingTop: safeArea.paddingTop,\n        paddingRight: safeArea.paddingRight,\n        paddingBottom: centered ? safeArea.paddingBottom : bottomSlot,\n        paddingLeft: safeArea.paddingLeft,\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          justifyContent: \"center\",\n          width: \"100%\",\n          opacity: enter,\n          translate: interpolate(\n            frame,\n            [0, DURATION.fast],\n            [\"0px 14px\", \"0px 0px\"],\n            {\n              extrapolateLeft: \"clamp\",\n              extrapolateRight: \"clamp\",\n              easing: EASING.enter,\n            },\n          ),\n          // Second beat: the plate settles slightly after it rises.\n          scale: interpolate(frame, [0, DURATION.normal], [0.972, 1], {\n            extrapolateLeft: \"clamp\",\n            extrapolateRight: \"clamp\",\n            easing: EASING.enter,\n            output: \"perceptual-scale\",\n          }),\n        }}\n      >\n        {plate}\n      </div>\n    </AbsoluteFill>\n  );\n}\n\nexport const CaptionScene: React.FC<CaptionSceneProps> = ({\n  captions,\n  combineTokensWithinMilliseconds = DEFAULT_CAPTION_PAGE_MS,\n  activeColor = COLORS.active,\n  inactiveColor = \"#ffffff\",\n  backgroundColor = \"transparent\",\n  placement = \"lower-third\",\n  mode = \"highlight\",\n  label = \"Caption\",\n  durationInFrames,\n}) => {\n  const config = useVideoConfig();\n  const { fps, width, height } = config;\n  // `useVideoConfig().durationInFrames` is always the *composition's* total\n  // length. When this scene sits inside a bounded `<Sequence>` (a\n  // TransitionSeries slot, for instance) that slot is shorter, so the caller\n  // must pass its own `durationInFrames` — otherwise the fallback below is\n  // the best available estimate.\n  const targetDuration = durationInFrames ?? config.durationInFrames;\n  const safeArea = getSafeAreaPadding({ width, height });\n  const fontSize = scaleFont(placement === \"center\" ? 54 : 48, width);\n  const bottomSlot = Math.max(\n    safeArea.paddingBottom,\n    Math.round(height * 0.1),\n  );\n\n  const pages = useMemo(\n    () => groupCaptionsIntoPages(captions, combineTokensWithinMilliseconds),\n    [captions, combineTokensWithinMilliseconds],\n  );\n\n  const captionZoneWidth = Math.min(\n    width - safeArea.paddingLeft - safeArea.paddingRight,\n    Math.round(width * (placement === \"center\" ? 0.74 : 0.82)),\n  );\n\n  // The last page's own `getPageSequenceTiming` duration is bounded by its\n  // own spoken span (there is no next page to bound it against) — that is\n  // shorter than however long the parent actually gives this scene to play.\n  // Wrapping every pass in `<Loop>` re-plays the caption pages for the rest\n  // of the assigned duration instead of leaving the scene blank once the\n  // transcript runs out.\n  const naturalDuration = useMemo(() => {\n    if (pages.length === 0) {\n      return 0;\n    }\n    const last = pages[pages.length - 1];\n    return Math.max(\n      1,\n      Math.round(((last.startMs + last.durationMs) / 1000) * fps),\n    );\n  }, [pages, fps]);\n\n  const loopTimes =\n    naturalDuration > 0\n      ? Math.max(1, Math.ceil(targetDuration / naturalDuration))\n      : 0;\n\n  const pageSequences = pages.map((page, index) => {\n    const { startFrame, durationInFrames: pageDuration } =\n      getPageSequenceTiming(pages, index, fps, combineTokensWithinMilliseconds);\n\n    if (pageDuration <= 0) {\n      return null;\n    }\n\n    return (\n      <Sequence\n        key={`${page.startMs}-${index}`}\n        from={Math.round(startFrame)}\n        durationInFrames={Math.round(pageDuration)}\n        layout=\"none\"\n      >\n        <CaptionPage\n          page={page}\n          mode={mode}\n          activeColor={activeColor}\n          inactiveColor={inactiveColor}\n          fontSize={fontSize}\n          placement={placement}\n          captionZoneWidth={captionZoneWidth}\n          safeArea={safeArea}\n          bottomSlot={bottomSlot}\n          label={label}\n        />\n      </Sequence>\n    );\n  });\n\n  return (\n    <AbsoluteFill style={{ backgroundColor, fontFamily }}>\n      {placement === \"lower-third\" ? (\n        <div\n          style={{\n            position: \"absolute\",\n            right: 0,\n            bottom: 0,\n            left: 0,\n            height: \"46%\",\n            background:\n              \"linear-gradient(to top, rgba(0,0,0,0.72), rgba(0,0,0,0))\",\n            pointerEvents: \"none\",\n          }}\n        />\n      ) : null}\n\n      {loopTimes > 0 ? (\n        <Loop\n          durationInFrames={naturalDuration}\n          times={loopTimes}\n          layout=\"none\"\n        >\n          {pageSequences}\n        </Loop>\n      ) : null}\n    </AbsoluteFill>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "signals",
    "drive": "data",
    "tier": "advanced",
    "tags": [
      "captions"
    ]
  }
}