{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "screen-recording",
  "title": "Screen Recording",
  "description": "Screen recording zoom: crop and auto-zoom a product demo capture. The treatment that turns a raw screen capture into a shot: crop the browser and OS chrome off each edge as a fraction, fit what is left to the frame, and run a keyframed camera that pushes in on whatever matters at a given frame and holds until the next move. Takes a video or a still through the same track.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/screen-recording/index.tsx",
      "content": "// Screen Recording · snapcn — https://snapcn.dev/docs/screens/screen-recording\n\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  getRemotionEnvironment,\n  Img,\n  interpolate,\n  OffthreadVideo,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\nexport type ScreenRecordingFit = \"cover\" | \"contain\";\nexport type ScreenRecordingEntrance = \"fade\" | \"none\";\n\n/**\n * Chrome to cut off the source, one fraction per edge — `{ top: 0.11 }` drops\n * the top 11% of the recording, which is about what a browser's tab strip and\n * address bar come to. Fractions of the source, never pixels: the component\n * then never has to know the file's intrinsic size, and the same numbers\n * survive a re-record at a different resolution. Measure once on a still and\n * divide — `chromePixels / recordingHeight`. All four default to 0.\n */\nexport interface ScreenCrop {\n  top?: number;\n  right?: number;\n  bottom?: number;\n  left?: number;\n}\n\n/**\n * One camera keyframe.\n *\n * A move eases from wherever the camera actually is at `at` to the pose named\n * here, over `duration` frames, and then HOLDS that pose until the next move's\n * `at`. There is no `hold` field — the hold is the gap between two moves.\n *\n * \"At frame 40, over 25 frames, push to 2.2x centred on (0.62, 0.31), hold\n * until frame 90, then pull back\" is two lines:\n *\n * ```tsx\n * camera={[\n *   { at: 40, duration: 25, zoom: 2.2, x: 0.62, y: 0.31 },\n *   { at: 90, duration: 25, zoom: 1 },\n * ]}\n * ```\n */\nexport interface CameraMove {\n  /**\n   * Frame this move starts, absolute on the component's own clock — i.e.\n   * compared against `useCurrentFrame() * speed`, not against the previous\n   * move. Moves are sorted by `at`; ties break on array order and the last one\n   * wins, the same rule `useStateTransition` uses for `Step`.\n   */\n  at: number;\n  /**\n   * Frames the move takes. Default 25 — a push worth about 0.8s at 30fps.\n   * A move whose `at + duration` runs past the next move's `at` is TRUNCATED,\n   * never blended: the next move takes over from the pose actually on screen at\n   * that frame, so the camera stays continuous but never averages two targets.\n   */\n  duration?: number;\n  /**\n   * Zoom this move lands on, ABSOLUTE — `1` is the fitted shot and `2.2` is\n   * 2.2x that. Never a multiplier on the previous move. Omit it and the move\n   * carries the previous zoom, which is what makes a pure pan one line.\n   */\n  zoom?: number;\n  /**\n   * Focal point the zoom centres on, normalized: 0 -> 1 left to right across\n   * the composition, origin top-left, measured on the shot as it sits at zoom\n   * 1. Omit it to carry the previous move's `x`. Defaults to 0.5 before the\n   * first move.\n   */\n  x?: number;\n  /**\n   * Focal point, normalized: 0 -> 1 top to bottom across the composition. Same\n   * space as `x`. Omit it to carry the previous move's `y`.\n   */\n  y?: number;\n}\n\nexport interface ScreenRecordingProps {\n  /**\n   * The recording. A video (.mp4/.webm/.mov/.m4v) plays through\n   * `<OffthreadVideo>` and anything else renders as an `<Img>`, so a single\n   * screenshot takes exactly the same camera track. A root-relative path\n   * (`/recordings/checkout.mp4`) is served by Next in the Player and rewritten\n   * through `staticFile()` in Remotion Studio and a render; http(s)/data/blob URLs pass through.\n   */\n  src: string;\n  /** Browser and OS chrome to cut away, as a fraction of each edge of the source. */\n  crop?: ScreenCrop;\n  /**\n   * The four edges of `crop`, flat. `crop` is the API you write by hand; these\n   * exist because the customizer's controls become props verbatim and\n   * `ControlType` has no object control, so a crop slider has to land on a\n   * number prop. Set here, an edge overrides the same edge of `crop`.\n   */\n  cropTop?: number;\n  cropRight?: number;\n  cropBottom?: number;\n  cropLeft?: number;\n  /**\n   * Width divided by height of the source file, if you know it (1920x1080 ->\n   * 1.778). Give it and `crop` is exact on the raw file. Omit it and the source\n   * is aspect-filled to the composition first, so `crop` is measured on the\n   * shot as it appears rather than on the file — the same thing whenever the\n   * two aspect ratios already match, which is the usual case.\n   *\n   * ponytail: it is compared against the COMPOSITION's aspect, not the box this\n   * component is mounted in. Everything else here is box-relative, so a\n   * recording inset in a device frame is correct — but its `crop` fractions\n   * carry the frame's aspect error (a laptop screen is 1.68, a 16:9 comp is\n   * 1.78, so ~6%). Omit `sourceAspect` there, or pass the container's aspect\n   * ratio's worth of it. Measuring the real box would need a ref and a state\n   * update, which is exactly the non-determinism this component cannot have.\n   */\n  sourceAspect?: number;\n  /**\n   * `cover` (default) fills the frame with the cropped region and centre-crops\n   * whichever axis is long — nothing is letterboxed and nothing is stretched,\n   * and the cropped region therefore *is* the frame, which is what lets camera\n   * coordinates and overlay coordinates be one space. `contain` fits the whole\n   * cropped region inside the frame and pads the rest with `backdropColor`;\n   * reach for it only when losing an edge is worse than bars.\n   */\n  fit?: ScreenRecordingFit;\n  /** Padding behind a `contain` fit. Defaults to the theme's `background`. */\n  backdropColor?: string;\n  /**\n   * The camera track, in `at` order. `DEMO_CAMERA` by default — a push and a\n   * pull-back, so the component shows what it is for with nothing passed. Pass\n   * `[]` for a locked-off shot at zoom 1; a treated recording with no camera is\n   * still the point of this component.\n   *\n   * A focal point is clamped to whatever the current zoom can actually hold\n   * (`clampFocus`), so a pull-back to zoom 1 recentres itself rather than\n   * dragging a band of `backdropColor` into frame. See `CameraMove` for how\n   * moves hold and how overlaps resolve.\n   */\n  camera?: CameraMove[];\n  /**\n   * Corner radius on the treated shot, in px. 0 (default) is edge-to-edge.\n   * A value here reads as a floating window, which only makes sense when the\n   * recording is inset inside something else.\n   */\n  radius?: number;\n  /**\n   * Frames to skip into the source video before frame 0 of the scene — the\n   * `trimBefore` of `<OffthreadVideo>`. Cut the dead air at the head of a take\n   * here instead of re-exporting the file. Ignored for images.\n   */\n  trimBefore?: number;\n  /** Play the recording's audio. Default false — a demo carries its own bed. */\n  audio?: boolean;\n  /**\n   * How the shot arrives. `fade` (default) is the same 18-frame\n   * fade-and-unblur `laptop-frame` and `phone-frame` run on screen media, so a\n   * recording dropped into either does not double up on two arrivals. `none`\n   * is up on frame 0.\n   */\n  entrance?: ScreenRecordingEntrance;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * Time multiplier. Every `at` in `camera` is measured against\n   * `useCurrentFrame() * speed`. Layers do not inherit it — pass the same\n   * `speed` to the overlays wrapping this one.\n   */\n  speed?: number;\n  className?: string;\n}\n\n// --- Constants -------------------------------------------------------------\n\n/**\n * The camera-move easing. A *moderate* decelerate — not quint/expo-out, which\n * cover their travel in the first third and then spend frames moving < 0.5px,\n * i.e. freeze on a frame clock. Same constant, same value, as `laptop-frame`'s\n * dolly, so a recording and a device frame move alike. See the motion-quality\n * skill.\n */\nexport const CAMERA_EASE = Easing.bezier(0.2, 0.6, 0.35, 1);\n\n/** Frames a camera move takes when `duration` is omitted. */\nexport const CAMERA_MOVE_FRAMES = 25;\n\n/** Hold past the last settle, so the shot reads as finished rather than cut. */\nexport const TAIL_FRAMES = 18;\n\n/** Fade-and-unblur window for `entrance: \"fade\"`. */\nexport const ENTRANCE_FRAMES = 18;\n\n/** The entrance starts a few frames in, exactly as `laptop-frame`'s does. */\nconst ENTRANCE_START = 4;\n\n/** Peak blur of the entrance, in px — `laptop-frame`'s `ScreenMedia` value. */\nconst ENTRANCE_BLUR = 8;\n\n/**\n * A push and a pull-back over the default recording. This is the component's\n * `camera` default rather than a config control: `ControlType` in\n * lib/customizer-config.ts has no array control, so the demo has to live on the\n * prop — the same way `terminal-simulator` carries `DEFAULT_LINES`.\n */\nexport const DEMO_CAMERA: CameraMove[] = [\n  { at: 34, duration: 25, zoom: 1.6, x: 0.36, y: 0.42 },\n  { at: 96, duration: 25, zoom: 1 },\n];\n\n// --- Pure helpers (unit-tested) -------------------------------------------\n\nexport interface CameraPose {\n  /** 1 is the fitted shot. */\n  zoom: number;\n  /** Focal point, normalized to the frame, origin top-left. */\n  x: number;\n  y: number;\n}\n\n/** The pose before any move has run. */\nexport const RESTING_POSE: CameraPose = { zoom: 1, x: 0.5, y: 0.5 };\n\nconst lerp = (a: number, b: number, p: number) => a + (b - a) * p;\n\n/**\n * Eased progress through one move. A zero or negative `duration` is a cut — it\n * cannot go through `interpolate`, whose input range must be increasing.\n */\nfunction moveProgress(frame: number, at: number, duration: number): number {\n  if (duration <= 0) return 1;\n  return interpolate(frame, [at, at + duration], [0, 1], {\n    easing: CAMERA_EASE,\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n}\n\n/**\n * The camera pose at `frame`, folding the track forward so a move that starts\n * mid-flight takes over from the pose actually on screen. Pure: same frame,\n * same answer, whatever order Remotion renders in.\n */\nexport function cameraPoseAt(frame: number, moves?: CameraMove[]): CameraPose {\n  if (!moves || moves.length === 0) return RESTING_POSE;\n  const sorted = moves\n    .map((move, index) => ({ move, index }))\n    .sort((a, b) => a.move.at - b.move.at || a.index - b.index)\n    // Same-`at` ties: the later array entry wins and the earlier tied entries\n    // never display, so they cannot act as a `from` pose either — the rule\n    // `useStateTransition` already applies to `Step`.\n    .filter(\n      (e, i, arr) => i === arr.length - 1 || arr[i + 1].move.at !== e.move.at,\n    )\n    .map((e) => e.move);\n\n  // `on` is the pose actually on screen; `target` is the last resolved target,\n  // which is what an omitted field carries forward.\n  let on = RESTING_POSE;\n  let target = RESTING_POSE;\n  let current: {\n    from: CameraPose;\n    to: CameraPose;\n    at: number;\n    dur: number;\n  } | null = null;\n\n  for (const move of sorted) {\n    if (move.at > frame) break;\n    if (current) {\n      // Truncation, not blending: sample the move in flight at the moment the\n      // next one takes over, and hand that pose on as the next move's `from`.\n      const p = moveProgress(move.at, current.at, current.dur);\n      on = {\n        zoom: lerp(current.from.zoom, current.to.zoom, p),\n        x: lerp(current.from.x, current.to.x, p),\n        y: lerp(current.from.y, current.to.y, p),\n      };\n    }\n    target = {\n      zoom: move.zoom ?? target.zoom,\n      x: move.x ?? target.x,\n      y: move.y ?? target.y,\n    };\n    current = {\n      from: on,\n      to: target,\n      at: move.at,\n      dur: move.duration ?? CAMERA_MOVE_FRAMES,\n    };\n  }\n\n  if (!current) return on;\n  const p = moveProgress(frame, current.at, current.dur);\n  return {\n    zoom: lerp(current.from.zoom, current.to.zoom, p),\n    x: lerp(current.from.x, current.to.x, p),\n    y: lerp(current.from.y, current.to.y, p),\n  };\n}\n\n/**\n * A focal point the current zoom can actually hold. At zoom `z` the camera sees\n * `1/z` of the shot, so its centre cannot get closer than `0.5/z` to an edge\n * without dragging `backdropColor` into frame — which is what `{ zoom: 1 }`\n * after an off-centre push would otherwise do to its own pull-back. At zoom 1\n * (or below) the only legal focal point is dead centre.\n */\nexport function clampFocus(value: number, zoom: number): number {\n  const lo = 0.5 / zoom;\n  const hi = 1 - lo;\n  if (lo >= hi) return 0.5;\n  return Math.min(Math.max(value, lo), hi);\n}\n\n/**\n * Natural length: the last move's `at + duration`, plus `TAIL_FRAMES`. A\n * locked-off shot (no moves) reports 0 and the composer uses the source's own\n * length instead.\n */\nexport function screenRecordingFrames(camera?: CameraMove[]): number {\n  if (!camera || camera.length === 0) return 0;\n  const end = Math.max(\n    ...camera.map((m) => m.at + (m.duration ?? CAMERA_MOVE_FRAMES)),\n  );\n  return Math.round(end + TAIL_FRAMES);\n}\n\nexport interface ShotLayout {\n  /** Cropped region in frame px: where it sits and how big it is. */\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  /** The media element inside that region — its size, and its offset from it. */\n  mediaWidth: number;\n  mediaHeight: number;\n  mediaLeft: number;\n  mediaTop: number;\n}\n\n/**\n * Where the cropped region and the media element land, in composition px.\n *\n * The source is measured in \"height = 1\" units, so `sourceAspect` is the only\n * thing the component ever needs to know about the file — omitted, it is the\n * composition's own aspect, which is exactly the \"crop measured on the fitted\n * shot\" reading documented on the prop.\n */\nexport function shotLayout(\n  width: number,\n  height: number,\n  crop: ScreenCrop | undefined,\n  fit: ScreenRecordingFit,\n  sourceAspect: number | undefined,\n): ShotLayout {\n  const left = crop?.left ?? 0;\n  const top = crop?.top ?? 0;\n  // A crop that eats the whole source would divide by zero and blank the frame;\n  // leave a sliver rather than an Infinity.\n  const keepX = Math.max(1 - left - (crop?.right ?? 0), 0.01);\n  const keepY = Math.max(1 - top - (crop?.bottom ?? 0), 0.01);\n  // A zero or negative `sourceAspect` divides by zero downstream and every\n  // returned size comes back NaN, which CSS drops silently — the shot just\n  // never appears. Fall back to the composition's aspect, i.e. to the\n  // documented \"omit it\" behaviour.\n  const aspect =\n    sourceAspect && sourceAspect > 0 ? sourceAspect : width / height;\n\n  // Cropped region in source units, then the scale that fits it to the frame.\n  const regionW = aspect * keepX;\n  const regionH = keepY;\n  const k =\n    fit === \"contain\"\n      ? Math.min(width / regionW, height / regionH)\n      : Math.max(width / regionW, height / regionH);\n\n  const w = regionW * k;\n  const h = regionH * k;\n  return {\n    left: (width - w) / 2,\n    top: (height - h) / 2,\n    width: w,\n    height: h,\n    mediaWidth: aspect * k,\n    mediaHeight: k,\n    // The region's top-left sits `crop.left`/`crop.top` into the scaled source.\n    mediaLeft: -left * aspect * k,\n    mediaTop: -top * k,\n  };\n}\n\n// --- Media ----------------------------------------------------------------\n\nconst isVideo = (src: string) => /\\.(mp4|webm|mov|m4v)(\\?|$)/i.test(src);\n\n/**\n * `shotLayout` works in composition px, but this component is an `AbsoluteFill`\n * and can be mounted in a box that is NOT the composition — a device frame's\n * screen (`laptop-frame`'s is 792x472), a split-screen `<Sequence>`. Laid out\n * in px there, the shot is sized for 1280x720 and clipped to the top-left\n * corner of the screen, and the camera centres the focal point on the\n * composition's centre rather than the box's.\n *\n * Dividing every offset by the composition size makes it a fraction of whatever\n * box the component actually lands in — identical when that box IS the frame,\n * correct when it is not, and with nothing to measure, so the layout stays a\n * pure function of the props.\n */\nconst pct = (value: number, of: number) => `${(value / of) * 100}%`;\n\n/**\n * A root-relative asset (`/recordings/checkout.mp4`) is served at the origin\n * root by Next in the Player, but Remotion Studio and a render serve `public/` through\n * `staticFile()` alone — so rewrite local paths everywhere but the Player, and pass\n * http(s)/data/blob URLs straight through.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (!isLocal || getRemotionEnvironment().isPlayer) return src;\n  // A value that already came out of staticFile() carries the static base\n  // (`/static-<hash>/…`); running it through again would prefix it twice.\n  const base = staticFile(\"_\").slice(0, -2);\n  if (base && src.startsWith(`${base}/`)) return src;\n  try {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  } catch {\n    return src;\n  }\n}\n\n// --- Component ------------------------------------------------------------\n\nexport function ScreenRecording({\n  src,\n  crop,\n  cropTop,\n  cropRight,\n  cropBottom,\n  cropLeft,\n  sourceAspect,\n  fit = \"cover\",\n  backdropColor,\n  camera = DEMO_CAMERA,\n  radius = 0,\n  trimBefore,\n  audio = false,\n  entrance = \"fade\",\n  theme,\n  mode,\n  speed = 1,\n  className,\n}: ScreenRecordingProps) {\n  const frame = useCurrentFrame() * speed;\n  const t = useSnapCnTheme(theme, mode);\n  const { width, height } = useVideoConfig();\n  const isRendering = getRemotionEnvironment().isRendering;\n\n  const shot = shotLayout(\n    width,\n    height,\n    {\n      top: cropTop ?? crop?.top,\n      right: cropRight ?? crop?.right,\n      bottom: cropBottom ?? crop?.bottom,\n      left: cropLeft ?? crop?.left,\n    },\n    fit,\n    sourceAspect,\n  );\n  const pose = cameraPoseAt(frame, camera);\n  const focusX = clampFocus(pose.x, pose.zoom);\n  const focusY = clampFocus(pose.y, pose.zoom);\n  // Screen = (content - focal) * zoom + centre, so the focal point lands dead\n  // centre at any zoom. With a `0 0` origin that is a plain translate + scale,\n  // and as a PERCENTAGE of the camera box it is `0.5 - focal * zoom` — which is\n  // the same number in a full-frame mount and correct in a smaller one.\n  const tx = (0.5 - focusX * pose.zoom) * 100;\n  const ty = (0.5 - focusY * pose.zoom) * 100;\n\n  const arrival =\n    entrance === \"none\"\n      ? 1\n      : interpolate(\n          frame,\n          [ENTRANCE_START, ENTRANCE_START + ENTRANCE_FRAMES],\n          [0, 1],\n          { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n        );\n\n  const mediaStyle: CSSProperties = {\n    position: \"absolute\",\n    // Percentages of the region box, not px — see `pct` above.\n    left: pct(shot.mediaLeft, shot.width),\n    top: pct(shot.mediaTop, shot.height),\n    width: pct(shot.mediaWidth, shot.width),\n    height: pct(shot.mediaHeight, shot.height),\n    // Tailwind's preflight sets `img { max-width: 100% }`, which can collapse a\n    // media element we size ourselves to 0px wide. Opt out. (motion-quality skill.)\n    maxWidth: \"none\",\n    // A no-op when `sourceAspect` is given (the box already is the file's\n    // aspect); when it is omitted this is what \"aspect-filled first\" means.\n    objectFit: \"cover\",\n    opacity: arrival,\n    // Dropped once the entrance is over rather than left at `blur(0px)`: a\n    // filter that is not `none` promotes the media to its own compositing\n    // layer, and a promoted layer under the camera's `scale()` is resampled\n    // from a stale raster instead of redrawn — across parallel tabs, in a\n    // render. `blur(0px)` is on the motion-quality skill's cargo-cult list for\n    // exactly that reason, and this shot is scaled on nearly every frame.\n    ...(arrival < 1\n      ? { filter: `blur(${(1 - arrival) * ENTRANCE_BLUR}px)` }\n      : null),\n  };\n  const resolved = resolveSrc(src);\n\n  return (\n    <AbsoluteFill\n      className={className}\n      style={{\n        backgroundColor: backdropColor ?? t.background,\n        borderRadius: radius,\n        overflow: \"hidden\",\n      }}\n    >\n      {/* Camera. will-change helps the live Player but is wrong in a render —\n          it rasterises the shot across parallel tabs. (motion-quality skill.) */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          transformOrigin: \"0 0\",\n          transform: `translate(${tx}%, ${ty}%) scale(${pose.zoom})`,\n          ...(isRendering ? {} : { willChange: \"transform\" as const }),\n        }}\n      >\n        {/* The cropped region. Clips the chrome off the source, and under\n            `contain` keeps it off the backdrop as well. */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: pct(shot.left, width),\n            top: pct(shot.top, height),\n            width: pct(shot.width, width),\n            height: pct(shot.height, height),\n            overflow: \"hidden\",\n          }}\n        >\n          {isVideo(src) ? (\n            <OffthreadVideo\n              src={resolved}\n              muted={!audio}\n              trimBefore={trimBefore}\n              // The camera runs on `frame * speed`; the footage has to as well,\n              // or a faster take zooms to a moment the video has not reached.\n              playbackRate={speed}\n              style={mediaStyle}\n            />\n          ) : (\n            <Img src={resolved} style={mediaStyle} />\n          )}\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n\n// ponytail: no `chrome=\"browser\"` preset. A fraction that guesses an unknown\n// recording's tab strip is wrong more often than right; `crop` is one division.\n// ponytail: no `children`. A slot here would be a second, zoom-locked overlay\n// space, which contradicts overlays living in frame coordinates — wrap this\n// component in cursor-track instead. Emphasis inside the shot is `camera`'s\n// job: a push to a focal point rides the footage by construction, which is\n// exactly what an overlay in frame coordinates cannot do.\n// ponytail: no per-move easing override. Add one when a track needs two feels.\n",
      "type": "registry:component",
      "target": "components/snap-cn/screen-recording.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/screens/screen-recording?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
