{
  "name": "comment-callout",
  "type": "registry:block",
  "description": "Viewer comment answered on screen: it lands from the feed, the ask is marked up, the creator hearts it, and the reply is typed and sent",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "ai-composer-utils",
    "code-syntax",
    "motion-tokens",
    "text-emphasis"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/comment-callout/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { stageScale } from \"@/remotion/lib/ai-composer-utils\";\nimport { CODE_THEMES } from \"@/remotion/lib/code-syntax\";\nimport { EASING } from \"@/remotion/lib/motion-tokens\";\nimport { markEmphasis, markerEdges } from \"@/remotion/lib/text-emphasis\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\n/** Reference stage the card is laid out against, then uniformly scaled. */\nconst REF_WIDTH = 1280;\nconst REF_HEIGHT = 720;\n\nconst CARD_WIDTH = 840;\nconst AVATAR = 68;\nconst BODY_SIZE = 40;\nconst BODY_LINE = 1.2;\n\n/** Characters per second the reply is typed at. */\nconst REPLY_CPS = 34;\n\nexport type CommentCalloutProps = {\n  /** The viewer comment being answered. */\n  body?: string;\n  /** Display name of the commenter. */\n  author?: string;\n  /** Social handle, also used on the reply line. */\n  handle?: string;\n  /** Avatar initials. Falls back to the first two letters of `author`. */\n  initials?: string;\n  /** Relative time shown next to the handle, e.g. \"2h\". */\n  timestamp?: string;\n  /**\n   * Substring of `body` to mark up as the creator emphasises the ask.\n   * Matched case-insensitively; omit to skip the highlight beat.\n   */\n  highlight?: string;\n  /** The answer typed back. Omit to end the scene on the comment itself. */\n  reply?: string;\n  /** Label above the composer. */\n  replyLabel?: string;\n  /** Like count before the creator hearts the comment. */\n  likes?: number;\n  accentColor?: string;\n  /** Overrides the page background behind the card. */\n  backgroundColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\n/** Drawn, not typed — emoji render inconsistently across platforms. */\nconst Heart: React.FC<{ size: number; color: string; fill: number }> = ({\n  size,\n  color,\n  fill,\n}) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M12 20.2s-7.4-4.6-7.4-9.6a4.2 4.2 0 0 1 7.4-2.7 4.2 4.2 0 0 1 7.4 2.7c0 5-7.4 9.6-7.4 9.6Z\"\n      stroke={color}\n      strokeWidth={1.8}\n      strokeLinejoin=\"round\"\n      fill={color}\n      fillOpacity={fill}\n    />\n  </svg>\n);\n\nconst Reply: React.FC<{ size: number; color: string }> = ({ size, color }) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M9.5 6.5 4 12l5.5 5.5M4.4 12H14a5.5 5.5 0 0 1 5.5 5.5v1\"\n      stroke={color}\n      strokeWidth={1.8}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst Send: React.FC<{ size: number; color: string }> = ({ size, color }) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M4 11.6 20 4l-7.6 16-2.1-6.3L4 11.6Z\"\n      stroke={color}\n      strokeWidth={1.8}\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst Check: React.FC<{ size: number; color: string; progress: number }> = ({\n  size,\n  color,\n  progress,\n}) => {\n  const length = 22;\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n      <path\n        d=\"M4.5 12.5l5 5 10-11\"\n        stroke={color}\n        strokeWidth={2.4}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeDasharray={length}\n        strokeDashoffset={length * (1 - progress)}\n      />\n    </svg>\n  );\n};\n\n/** Word gap in the comment body, also bridged by the marker. */\nconst WORD_GAP = \"0.3em\";\n\n/**\n * A viewer comment staged the way a creator actually uses one: it arrives from\n * the feed, the ask gets marked up, the creator hearts it, and the answer is\n * typed back and sent — rather than a quote card fading into place.\n */\nexport const CommentCallout: React.FC<CommentCalloutProps> = ({\n  body = \"Can you break down how you built that transition?\",\n  author = \"Alex Chen\",\n  handle = \"@alexchen\",\n  initials,\n  timestamp = \"2h\",\n  highlight = \"how you built that transition\",\n  reply = \"Full breakdown drops Thursday — here's the short version.\",\n  replyLabel,\n  likes = 128,\n  accentColor = \"#F472B6\",\n  backgroundColor,\n  theme = \"dark\",\n  speed = 1,\n}) => {\n  const rawFrame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const palette = CODE_THEMES[theme];\n  const page = backgroundColor ?? palette.page;\n  const scale = stageScale(width, height, REF_WIDTH, REF_HEIGHT);\n  const frame = rawFrame * speed;\n\n  const seconds = (value: number) => value * fps;\n  const clamp = {\n    extrapolateLeft: \"clamp\" as const,\n    extrapolateRight: \"clamp\" as const,\n  };\n\n  const words = markEmphasis(body, highlight);\n  const markedCount = words.filter((word) => word.marked).length;\n  const avatarText =\n    initials ?? (author ? author.slice(0, 2).toUpperCase() : \"??\");\n\n  // --- Beat plan ----------------------------------------------------------\n  const markStart = seconds(0.85);\n  const markStep = seconds(0.09);\n  const markEnd = markStart + Math.max(markedCount - 1, 0) * markStep + seconds(0.34);\n  const heartAt = markedCount > 0 ? markEnd + seconds(0.14) : seconds(1.0);\n  const composerAt = heartAt + seconds(0.34);\n  const typeStart = composerAt + seconds(0.3);\n  const typeEnd = typeStart + (reply.length / REPLY_CPS) * fps;\n  const sendAt = typeEnd + seconds(0.3);\n\n  const cardEnter = spring({\n    fps,\n    frame,\n    config: { damping: 17, stiffness: 125, mass: 0.85 },\n  });\n  const identityIn = interpolate(\n    frame,\n    [seconds(0.14), seconds(0.5)],\n    [0, 1],\n    { easing: EASING.enter, ...clamp },\n  );\n  const bodyIn = interpolate(frame, [seconds(0.26), seconds(0.66)], [0, 1], {\n    easing: EASING.enter,\n    ...clamp,\n  });\n\n  const hearted = interpolate(\n    frame,\n    [heartAt, heartAt + seconds(0.26)],\n    [0, 1],\n    { easing: EASING.enter, ...clamp },\n  );\n  // The count flips at the moment the heart fills, and rises as it flips.\n  const countFlip = interpolate(\n    frame,\n    [heartAt + seconds(0.08), heartAt + seconds(0.3)],\n    [0, 1],\n    { easing: EASING.enter, ...clamp },\n  );\n\n  const hasReply = reply.length > 0;\n  const composerOpen = hasReply\n    ? interpolate(frame, [composerAt, composerAt + seconds(0.32)], [0, 1], {\n        easing: EASING.enter,\n        ...clamp,\n      })\n    : 0;\n  const typed = hasReply\n    ? Math.round(interpolate(frame, [typeStart, typeEnd], [0, reply.length], clamp))\n    : 0;\n  const sent = hasReply\n    ? interpolate(frame, [sendAt, sendAt + seconds(0.3)], [0, 1], {\n        easing: EASING.enter,\n        ...clamp,\n      })\n    : 0;\n  // Press dip on the send control, right as the reply commits.\n  const press = hasReply\n    ? interpolate(\n        frame,\n        [sendAt - seconds(0.06), sendAt, sendAt + seconds(0.16)],\n        [0, 1, 0],\n        clamp,\n      )\n    : 0;\n  const caretOn =\n    hasReply &&\n    frame > composerAt + seconds(0.18) &&\n    sent < 0.5 &&\n    (frame < typeStart || typed >= reply.length\n      ? Math.floor(frame / (fps * 0.4)) % 2 === 0\n      : true);\n\n  const composerHeight = 118;\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: page,\n        fontFamily,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        overflow: \"hidden\",\n        position: \"relative\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(ellipse 65% 55% at 50% 40%, ${accentColor}1F, transparent 70%)`,\n          opacity: bodyIn,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background:\n            theme === \"dark\"\n              ? \"radial-gradient(ellipse 120% 90% at 50% 50%, transparent 45%, rgba(0,0,0,0.55) 100%)\"\n              : \"radial-gradient(ellipse 120% 90% at 50% 50%, transparent 55%, rgba(15,18,25,0.07) 100%)\",\n        }}\n      />\n\n      <div\n        style={{\n          width: CARD_WIDTH,\n          transform: `scale(${scale * interpolate(cardEnter, [0, 1], [0.965, 1])}) translateY(${interpolate(cardEnter, [0, 1], [42, 0])}px)`,\n          opacity: cardEnter,\n          borderRadius: 22,\n          background: palette.window,\n          border: `1px solid ${palette.border}`,\n          boxShadow: `inset 0 1px 0 ${palette.highlight}, 0 34px 90px ${palette.shadow}`,\n          overflow: \"hidden\",\n        }}\n      >\n        <div style={{ padding: \"30px 34px 24px\" }}>\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 16,\n              opacity: identityIn,\n              transform: `translateY(${(1 - identityIn) * 8}px)`,\n            }}\n          >\n            <div\n              style={{\n                width: AVATAR,\n                height: AVATAR,\n                flexShrink: 0,\n                borderRadius: \"50%\",\n                background: accentColor,\n                color: palette.page,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                fontSize: 25,\n                fontWeight: 700,\n                letterSpacing: 0.5,\n              }}\n            >\n              {avatarText}\n            </div>\n            <div style={{ display: \"flex\", alignItems: \"baseline\", gap: 12 }}>\n              <span\n                style={{ color: palette.fg, fontSize: 27, fontWeight: 600 }}\n              >\n                {author}\n              </span>\n              <span style={{ color: palette.dim, fontSize: 22 }}>{handle}</span>\n              {timestamp ? (\n                <span style={{ color: palette.faint, fontSize: 22 }}>\n                  · {timestamp}\n                </span>\n              ) : null}\n            </div>\n          </div>\n\n          <p\n            style={{\n              margin: \"22px 0 0\",\n              paddingLeft: AVATAR + 16,\n              color: palette.fg,\n              fontSize: BODY_SIZE,\n              lineHeight: BODY_LINE,\n              fontWeight: 600,\n              letterSpacing: \"-0.01em\",\n              opacity: bodyIn,\n              transform: `translateY(${(1 - bodyIn) * 10}px)`,\n            }}\n          >\n            {words.map((word, index) => {\n              // Marker sweeps word by word, so it tracks a wrapped phrase.\n              const mark = word.marked\n                ? interpolate(\n                    frame,\n                    [\n                      markStart + word.order * markStep,\n                      markStart + word.order * markStep + seconds(0.34),\n                    ],\n                    [0, 1],\n                    { easing: EASING.enter, ...clamp },\n                  )\n                : 0;\n\n              return (\n                <span\n                  key={`${word.text}-${index}`}\n                  style={{\n                    position: \"relative\",\n                    display: \"inline-block\",\n                    marginRight: WORD_GAP,\n                  }}\n                >\n                  {word.marked ? (\n                    <span\n                      style={{\n                        position: \"absolute\",\n                        ...markerEdges(words, index, WORD_GAP),\n                        top: \"16%\",\n                        bottom: \"2%\",\n                        background: `${accentColor}42`,\n                        transform: `scaleX(${mark})`,\n                        transformOrigin: \"left center\",\n                      }}\n                    />\n                  ) : null}\n                  <span style={{ position: \"relative\" }}>{word.text}</span>\n                </span>\n              );\n            })}\n          </p>\n\n          <div\n            style={{\n              marginTop: 24,\n              paddingLeft: AVATAR + 16,\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 26,\n              opacity: identityIn,\n            }}\n          >\n            <span\n              style={{\n                display: \"inline-flex\",\n                alignItems: \"center\",\n                gap: 9,\n                color: hearted > 0.5 ? accentColor : palette.faint,\n                fontSize: 22,\n                fontWeight: 500,\n                transform: `scale(${1 + Math.sin(Math.PI * hearted) * 0.09})`,\n                transformOrigin: \"left center\",\n              }}\n            >\n              <Heart\n                size={24}\n                color={hearted > 0.5 ? accentColor : palette.faint}\n                fill={hearted}\n              />\n              <span\n                style={{\n                  display: \"inline-block\",\n                  transform: `translateY(${-countFlip * 2}px)`,\n                }}\n              >\n                {likes + (countFlip > 0.5 ? 1 : 0)}\n              </span>\n            </span>\n            <span\n              style={{\n                display: \"inline-flex\",\n                alignItems: \"center\",\n                gap: 9,\n                color: composerOpen > 0.2 ? accentColor : palette.faint,\n                fontSize: 22,\n                fontWeight: 500,\n              }}\n            >\n              <Reply\n                size={24}\n                color={composerOpen > 0.2 ? accentColor : palette.faint}\n              />\n              {replyLabel ?? \"Reply\"}\n            </span>\n          </div>\n        </div>\n\n        {hasReply ? (\n          <div\n            style={{\n              height: composerHeight * composerOpen,\n              overflow: \"hidden\",\n              borderTop: `1px solid ${palette.border}`,\n              background: `${accentColor}${sent > 0.5 ? \"14\" : \"0A\"}`,\n            }}\n          >\n            <div\n              style={{\n                padding: \"18px 34px 20px\",\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: 16,\n                // Slides up out of the fold rather than stretching the type.\n                transform: `translateY(${(composerOpen - 1) * 12}px)`,\n              }}\n            >\n              <div style={{ minWidth: 0, flex: 1 }}>\n                <div\n                  style={{\n                    color: palette.faint,\n                    fontSize: 17,\n                    fontWeight: 500,\n                    letterSpacing: 0.3,\n                    marginBottom: 6,\n                  }}\n                >\n                  Replying to {handle}\n                </div>\n                <div\n                  style={{\n                    color: sent > 0.4 ? palette.fg : palette.dim,\n                    fontSize: 25,\n                    fontWeight: 500,\n                    lineHeight: 1.25,\n                    whiteSpace: \"pre-wrap\",\n                  }}\n                >\n                  {reply.slice(0, typed)}\n                  {caretOn ? (\n                    <span\n                      style={{\n                        display: \"inline-block\",\n                        width: 2,\n                        height: \"1.05em\",\n                        marginLeft: 2,\n                        verticalAlign: \"-0.16em\",\n                        background: accentColor,\n                      }}\n                    />\n                  ) : null}\n                </div>\n              </div>\n              <div\n                style={{\n                  width: 46,\n                  height: 46,\n                  flexShrink: 0,\n                  borderRadius: \"50%\",\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  background:\n                    sent > 0.5 || typed >= reply.length\n                      ? accentColor\n                      : `${accentColor}26`,\n                  transform: `scale(${1 - press * 0.12})`,\n                }}\n              >\n                {sent > 0.02 ? (\n                  <Check size={22} color={palette.page} progress={sent} />\n                ) : (\n                  <Send\n                    size={21}\n                    color={typed >= reply.length ? palette.page : accentColor}\n                  />\n                )}\n              </div>\n            </div>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "time",
    "tier": "advanced",
    "tags": [
      "creator",
      "social"
    ]
  }
}