{
  "name": "chat-to-preview",
  "type": "registry:block",
  "description": "Prompt-to-render loop: the ask types into the composer and sends into the thread, the answer streams back, and the preview assembles from wireframe to rendered scene",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/chat-to-preview/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, spring, 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\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type ChatMessage = {\n  role: \"user\" | \"assistant\";\n  text: string;\n};\n\nexport type ChatToPreviewProps = {\n  /** The exchange, in order. User turns type and send; assistant turns stream. */\n  messages?: ChatMessage[];\n  /** Title the finished preview renders. */\n  previewTitle?: string;\n  /** Supporting line under the preview title. */\n  previewCaption?: string;\n  /** Name of the preview surface, shown in its header. */\n  previewLabel?: string;\n  /** Placeholder in the composer before anything is typed. */\n  placeholder?: string;\n  accentColor?: string;\n  backgroundColor?: string;\n  theme?: \"dark\" | \"light\";\n  /** Animation speed multiplier. */\n  speed?: number;\n};\n\nconst DEFAULT_MESSAGES: ChatMessage[] = [\n  { role: \"user\", text: \"Open on a title card with a phosphor accent.\" },\n  { role: \"assistant\", text: \"Building a centred title scene with rim light.\" },\n];\n\n/** Characters typed per second in the composer. */\nconst TYPE_CPS = 38;\n/** Words streamed per second by the assistant. */\nconst STREAM_WPS = 8;\n\n/** Beat plan in seconds. */\nconst T = {\n  /** First keystroke. */\n  start: 0.5,\n  /** Held after the composer fills, before the send. */\n  beforeSend: 0.2,\n  /** Bubble flight from the composer into the thread. */\n  send: 0.34,\n  /** Assistant pause before it starts answering. */\n  think: 0.62,\n  /** Gap after a turn finishes. */\n  turnGap: 0.24,\n  /** How long the preview takes to assemble once the answer starts. */\n  build: 1.9,\n  /** Fade from wireframe to the rendered result. */\n  resolve: 0.5,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ntype Turn = {\n  message: ChatMessage;\n  /** Composer typing window (user turns only). */\n  typeFrom: number;\n  /** When the bubble exists in the thread. */\n  landAt: number;\n  /** Streaming window start (assistant turns only). */\n  streamFrom: number;\n};\n\n/** Lay the exchange out on one clock, so no beat needs a magic frame number. */\nfunction schedule(messages: ChatMessage[]): Turn[] {\n  let cursor = T.start;\n  return messages.map((message) => {\n    if (message.role === \"user\") {\n      const typeFrom = cursor;\n      const landAt = typeFrom + message.text.length / TYPE_CPS + T.beforeSend;\n      cursor = landAt + T.send + T.turnGap;\n      return { message, typeFrom, landAt, streamFrom: 0 };\n    }\n    const landAt = cursor;\n    const streamFrom = landAt + T.think;\n    cursor = streamFrom + message.text.split(/\\s+/).length / STREAM_WPS + T.turnGap;\n    return { message, typeFrom: 0, landAt, streamFrom };\n  });\n}\n\nconst CheckGlyph: React.FC<{\n  size: number;\n  color: string;\n  progress: number;\n}> = ({ size, color, progress }) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\">\n    <path\n      d=\"M4.8 12.6l4.9 4.9 9.5-10.4\"\n      stroke={color}\n      strokeWidth={2.8}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeDasharray={22}\n      strokeDashoffset={22 * (1 - progress)}\n    />\n  </svg>\n);\n\nconst SendGlyph: React.FC<{ size: number; color: string }> = ({\n  size,\n  color,\n}) => (\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\n/** Rotating arc — a spinner that renders identically on every platform. */\nconst Spinner: React.FC<{ size: number; color: string; turn: number }> = ({\n  size,\n  color,\n  turn,\n}) => (\n  <svg\n    width={size}\n    height={size}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    style={{ transform: `rotate(${turn * 360}deg)` }}\n  >\n    <circle\n      cx={12}\n      cy={12}\n      r={9}\n      stroke={color}\n      strokeWidth={2.2}\n      strokeLinecap=\"round\"\n      strokeDasharray={`${2 * Math.PI * 9 * 0.28} ${2 * Math.PI * 9}`}\n    />\n  </svg>\n);\n\n/**\n * The prompt-to-render loop as it actually plays: the ask is typed into the\n * composer and sent up into the thread, the answer streams back word by word,\n * and the preview assembles alongside it — wireframe blocks first, then the\n * rendered scene — rather than one layout cross-fading into another.\n */\nexport const ChatToPreview: React.FC<ChatToPreviewProps> = ({\n  messages = DEFAULT_MESSAGES,\n  previewTitle = \"Ship the scene\",\n  previewCaption = \"Centred title, phosphor rim light\",\n  previewLabel = \"Preview\",\n  placeholder = \"Describe the scene…\",\n  accentColor = \"#E8B86D\",\n  backgroundColor,\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  const u = Math.min(stage.w / 1120, stage.h / 620);\n  const gap = 26 * u;\n\n  const chat = portrait\n    ? { w: stage.w, h: stage.h * 0.48 }\n    : { w: stage.w * 0.42, h: stage.h };\n  const preview = portrait\n    ? { x: 0, y: chat.h + gap, w: stage.w, h: stage.h - chat.h - gap }\n    : { x: chat.w + gap, y: 0, w: stage.w - chat.w - gap, h: stage.h };\n\n  const turns = schedule(messages);\n  const answer = turns\n    .filter((turn) => turn.message.role === \"assistant\")\n    .at(-1);\n  /** The preview starts assembling the moment the answer starts coming back. */\n  const buildFrom = answer ? answer.streamFrom : T.start;\n  const build = ease(buildFrom, buildFrom + T.build, EASING.editorial);\n  const resolve = ease(\n    buildFrom + T.build - 0.1,\n    buildFrom + T.build - 0.1 + T.resolve,\n  );\n  const ready = ease(buildFrom + T.build + 0.25, buildFrom + T.build + 0.6);\n\n  const panelIn = spring({\n    frame,\n    fps,\n    config: { damping: 18, stiffness: 120, mass: 0.8 },\n  });\n\n  /** The user turn currently being typed, if any. */\n  const typing = turns.find(\n    (turn) =>\n      turn.message.role === \"user\" && now >= turn.typeFrom && now < turn.landAt,\n  );\n  const composerText = typing\n    ? typing.message.text.slice(\n        0,\n        Math.floor((now - typing.typeFrom) * TYPE_CPS),\n      )\n    : \"\";\n  const caretOn = Math.floor(now * 2) % 2 === 0;\n\n  const chatPad = 22 * u;\n  const composerH = 60 * 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% 60% at ${\n            portrait ? 50 : 72\n          }% ${portrait ? 72 : 45}%, ${accentColor}${\n            build > 0 ? \"1E\" : \"10\"\n          }, transparent 70%)`,\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        {/* Thread */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            width: chat.w,\n            height: chat.h,\n            borderRadius: 22 * u,\n            background: palette.window,\n            border: `1px solid ${palette.border}`,\n            boxShadow: `inset 0 1px 0 ${palette.highlight}, 0 ${24 * u}px ${\n              64 * u\n            }px ${palette.shadow}`,\n            opacity: panelIn,\n            transform: `translateY(${(1 - panelIn) * 18 * u}px)`,\n            padding: chatPad,\n            paddingBottom: chatPad + composerH + 14 * u,\n            display: \"flex\",\n            flexDirection: \"column\",\n            justifyContent: \"flex-end\",\n            gap: 12 * u,\n            overflow: \"hidden\",\n          }}\n        >\n          {turns.map((turn, index) => {\n            if (now < turn.landAt) {\n              return null;\n            }\n            const isUser = turn.message.role === \"user\";\n            // A user bubble flies up out of the composer; an assistant bubble\n            // opens in place beneath it.\n            const land = interpolate(\n              now,\n              [turn.landAt, turn.landAt + T.send],\n              [0, 1],\n              { easing: EASING.enter, ...clamp },\n            );\n            const words = turn.message.text.split(/\\s+/);\n            const streamed = Math.floor(\n              Math.max(now - turn.streamFrom, 0) * STREAM_WPS,\n            );\n            const thinking = !isUser && now < turn.streamFrom;\n\n            return (\n              <div\n                key={`${turn.message.role}-${index}`}\n                style={{\n                  alignSelf: isUser ? \"flex-end\" : \"flex-start\",\n                  maxWidth: \"88%\",\n                  padding: `${13 * u}px ${17 * u}px`,\n                  borderRadius: 16 * u,\n                  borderBottomRightRadius: isUser ? 5 * u : 16 * u,\n                  borderBottomLeftRadius: isUser ? 16 * u : 5 * u,\n                  background: isUser ? palette.band : `${accentColor}1A`,\n                  border: `1px solid ${\n                    isUser ? palette.border : `${accentColor}44`\n                  }`,\n                  color: palette.fg,\n                  fontSize: 23 * u,\n                  lineHeight: 1.36,\n                  opacity: land,\n                  transform: `translateY(${\n                    (1 - land) * (isUser ? 46 * u : 12 * u)\n                  }px) scale(${interpolate(land, [0, 1], [0.96, 1])})`,\n                }}\n              >\n                {thinking ? (\n                  <span style={{ display: \"flex\", gap: 6 * u, padding: 4 * u }}>\n                    {[0, 1, 2].map((dot) => (\n                      <span\n                        key={dot}\n                        style={{\n                          width: 8 * u,\n                          height: 8 * u,\n                          borderRadius: \"50%\",\n                          background: accentColor,\n                          opacity: interpolate(\n                            Math.sin((now * 6 - dot * 0.6) * Math.PI),\n                            [-1, 1],\n                            [0.25, 1],\n                          ),\n                        }}\n                      />\n                    ))}\n                  </span>\n                ) : (\n                  words\n                    .slice(0, isUser ? words.length : Math.max(streamed, 1))\n                    .join(\" \")\n                )}\n              </div>\n            );\n          })}\n\n          {/* Composer */}\n          <div\n            style={{\n              position: \"absolute\",\n              left: chatPad,\n              right: chatPad,\n              bottom: chatPad,\n              height: composerH,\n              borderRadius: 14 * u,\n              background: palette.band,\n              border: `1px solid ${\n                composerText ? `${accentColor}55` : palette.border\n              }`,\n              display: \"flex\",\n              alignItems: \"center\",\n              paddingLeft: 16 * u,\n              paddingRight: 10 * u,\n              gap: 10 * u,\n              color: composerText ? palette.fg : palette.faint,\n              fontSize: 22 * u,\n              overflow: \"hidden\",\n            }}\n          >\n            <span style={{ whiteSpace: \"nowrap\" }}>\n              {composerText || placeholder}\n              {typing ? (\n                <span\n                  style={{\n                    display: \"inline-block\",\n                    width: 2 * u,\n                    height: 22 * u,\n                    marginLeft: 3 * u,\n                    verticalAlign: \"-3px\",\n                    background: accentColor,\n                    opacity: caretOn ? 1 : 0.15,\n                  }}\n                />\n              ) : null}\n            </span>\n            <span\n              style={{\n                marginLeft: \"auto\",\n                display: \"grid\",\n                placeItems: \"center\",\n                width: 38 * u,\n                height: 38 * u,\n                borderRadius: 10 * u,\n                flexShrink: 0,\n                background: composerText ? accentColor : palette.border,\n              }}\n            >\n              <SendGlyph\n                size={21 * u}\n                color={composerText ? palette.page : palette.faint}\n              />\n            </span>\n          </div>\n        </div>\n\n        {/* Preview surface */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: preview.x,\n            top: preview.y,\n            width: preview.w,\n            height: preview.h,\n            borderRadius: 22 * u,\n            background: palette.window,\n            border: `1px solid ${\n              build > 0 ? `${accentColor}55` : palette.border\n            }`,\n            boxShadow: `inset 0 1px 0 ${palette.highlight}, 0 ${24 * u}px ${\n              64 * u\n            }px ${palette.shadow}`,\n            opacity: panelIn,\n            transform: `translateY(${(1 - panelIn) * 18 * u}px)`,\n            display: \"grid\",\n            gridTemplateRows: `${58 * u}px 1fr`,\n            overflow: \"hidden\",\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 10 * u,\n              padding: `0 ${18 * u}px`,\n              borderBottom: `1px solid ${palette.border}`,\n              color: palette.dim,\n              fontSize: 18 * u,\n              fontWeight: 600,\n            }}\n          >\n            <span>{previewLabel}</span>\n            <span\n              style={{\n                marginLeft: \"auto\",\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: 8 * u,\n                color: ready > 0 ? accentColor : palette.faint,\n                fontWeight: 500,\n              }}\n            >\n              {ready > 0 ? (\n                <>\n                  <CheckGlyph\n                    size={20 * u}\n                    color={accentColor}\n                    progress={ready}\n                  />\n                  Ready\n                </>\n              ) : build > 0 ? (\n                <>\n                  <Spinner size={20 * u} color={accentColor} turn={now * 1.1} />\n                  Rendering\n                </>\n              ) : (\n                \"Idle\"\n              )}\n            </span>\n          </div>\n\n          <div style={{ position: \"relative\", overflow: \"hidden\" }}>\n            {/* Wireframe the scene is assembled from */}\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                padding: 30 * u,\n                display: \"flex\",\n                flexDirection: \"column\",\n                justifyContent: \"center\",\n                alignItems: \"center\",\n                gap: 14 * u,\n                opacity: 1 - resolve,\n              }}\n            >\n              {[0.5, 1, 0.72, 0.55].map((share, index) => {\n                const block = interpolate(\n                  build,\n                  [index * 0.2, index * 0.2 + 0.34],\n                  [0, 1],\n                  clamp,\n                );\n                const isMedia = index === 1;\n                return (\n                  <div\n                    key={share}\n                    style={{\n                      width: `${share * 100}%`,\n                      height: isMedia ? \"38%\" : 24 * u,\n                      borderRadius: isMedia ? 14 * u : 999,\n                      // The media block is tinted so the wireframe reads as a\n                      // scene taking shape, not as four grey bars.\n                      background: isMedia ? `${accentColor}16` : palette.band,\n                      border: `1px solid ${\n                        isMedia ? `${accentColor}3A` : palette.border\n                      }`,\n                      boxShadow: `inset 0 1px 0 ${palette.highlight}`,\n                      opacity: block,\n                      transform: `scaleX(${interpolate(\n                        block,\n                        [0, 1],\n                        [0.7, 1],\n                      )})`,\n                    }}\n                  />\n                );\n              })}\n            </div>\n\n            {/* The rendered result */}\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                flexDirection: \"column\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                gap: 14 * u,\n                padding: 30 * u,\n                textAlign: \"center\",\n                background: `radial-gradient(ellipse 70% 60% at 50% 38%, ${accentColor}26, transparent 70%)`,\n                opacity: resolve,\n                transform: `scale(${interpolate(resolve, [0, 1], [1.03, 1])})`,\n              }}\n            >\n              <div\n                style={{\n                  color: palette.fg,\n                  fontSize: 52 * u,\n                  fontWeight: 700,\n                  lineHeight: 1.05,\n                  letterSpacing: \"-0.02em\",\n                }}\n              >\n                {previewTitle}\n              </div>\n              <div style={{ color: palette.dim, fontSize: 22 * u }}>\n                {previewCaption}\n              </div>\n            </div>\n\n            {/* One sheen across the surface as it resolves */}\n            <div\n              style={{\n                position: \"absolute\",\n                top: 0,\n                bottom: 0,\n                width: preview.w * 0.4,\n                left: interpolate(\n                  resolve,\n                  [0, 1],\n                  [-preview.w * 0.4, preview.w],\n                ),\n                background: `linear-gradient(90deg, transparent, ${accentColor}22, transparent)`,\n                opacity: resolve > 0 && resolve < 1 ? 1 : 0,\n              }}\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "time",
    "tier": "advanced",
    "tags": [
      "ui"
    ]
  }
}