{
  "name": "media-frame",
  "type": "registry:block",
  "description": "One piece of media presented: the frame opens like a shutter under a slow push, then the title masks up and the caption settles",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts",
    "@remotion/media"
  ],
  "registryDependencies": [
    "code-syntax",
    "layout",
    "media-utils",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/media-frame/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 MediaFrameProps = {\n  src: string;\n  /** Headline above the frame. */\n  title?: string;\n  /** Supporting line under the frame. */\n  caption?: string;\n  /** Small label above the title — chapter, source, timestamp. */\n  eyebrow?: string;\n  fit?: MediaFit;\n  /**\n   * Aspect the frame itself is cut to. The frame is sized to this and centred,\n   * so `contain` media fills it instead of sitting in a letterbox.\n   */\n  aspect?: number;\n  /** Corner radius in composition pixels. Defaults to a scaled 20. */\n  radius?: 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  /** Shutter opening on the media. */\n  open: 0,\n  openTo: 0.62,\n  eyebrow: 0.24,\n  title: 0.34,\n  caption: 0.58,\n  /** The slow push runs the whole scene. */\n  push: 6,\n} as const;\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/**\n * One piece of media actually presented: the frame opens on it like a shutter\n * while a slow push runs underneath, then the title masks up from the frame's\n * edge and the caption settles beneath — rather than three elements fading in\n * over a static box.\n */\nexport const MediaFrame: React.FC<MediaFrameProps> = ({\n  src,\n  title,\n  caption,\n  eyebrow,\n  fit = \"contain\",\n  aspect = 16 / 9,\n  radius,\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 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    w: width - safe.paddingLeft - safe.paddingRight,\n    h: height - safe.paddingTop - safe.paddingBottom,\n  };\n  const u = Math.min(stage.w / 1120, stage.h / 620);\n\n  const open = ease(T.open, T.openTo, EASING.editorial);\n  const eyebrowIn = eyebrow ? ease(T.eyebrow, T.eyebrow + 0.4) : 0;\n  const titleIn = title ? ease(T.title, T.title + 0.45) : 0;\n  const captionIn = caption ? ease(T.caption, T.caption + 0.45) : 0;\n  /** Slow push under the frame — the reason the shot feels alive on a hold. */\n  const push = ease(0, T.push, EASING.editorial);\n\n  // Reserve only what is shown: with no title and no caption the frame owns\n  // the whole safe area rather than sitting above an empty band.\n  const titleSize = 46 * u;\n  const eyebrowBlock = eyebrow ? 34 * u : 0;\n  const titleBlock = title ? titleSize * 1.1 + 18 * u : 0;\n  const captionBlock = caption ? 30 * u + 18 * u : 0;\n  const headerH = eyebrowBlock + titleBlock;\n\n  const availableH = stage.h - headerH - captionBlock;\n  const frameW = Math.min(stage.w, availableH * aspect);\n  const frameH = frameW / aspect;\n  const cornerRadius = radius ?? 20 * 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 70% 55% at 50% 45%, ${accentColor}12, transparent 72%)`,\n        }}\n      />\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: safe.paddingLeft,\n          top: safe.paddingTop,\n          width: stage.w,\n          height: stage.h,\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n        }}\n      >\n        {eyebrow ? (\n          <div\n            style={{\n              width: frameW,\n              color: accentColor,\n              fontSize: 20 * u,\n              fontWeight: 600,\n              letterSpacing: \"0.12em\",\n              textTransform: \"uppercase\",\n              height: eyebrowBlock,\n              opacity: eyebrowIn,\n              transform: `translateY(${(1 - eyebrowIn) * 8 * u}px)`,\n            }}\n          >\n            {eyebrow}\n          </div>\n        ) : null}\n\n        {title ? (\n          // Masked so the headline rises out of its own line rather than fading.\n          <div\n            style={{\n              width: frameW,\n              height: titleBlock,\n              overflow: \"hidden\",\n              display: \"flex\",\n              alignItems: \"flex-start\",\n            }}\n          >\n            <div\n              style={{\n                color: palette.fg,\n                fontSize: titleSize,\n                fontWeight: 700,\n                lineHeight: 1.1,\n                letterSpacing: \"-0.02em\",\n                transform: `translateY(${(1 - titleIn) * titleSize}px)`,\n              }}\n            >\n              {title}\n            </div>\n          </div>\n        ) : null}\n\n        <div\n          style={{\n            width: frameW,\n            height: frameH,\n            borderRadius: cornerRadius,\n            overflow: \"hidden\",\n            position: \"relative\",\n            background: palette.window,\n            border: `1px solid ${palette.border}`,\n            boxShadow: `inset 0 1px 0 ${palette.highlight}, 0 ${\n              26 * u\n            }px ${74 * u}px ${palette.shadow}`,\n            // Shutter: the frame opens from its centre line outwards.\n            clipPath: `inset(${(1 - open) * 50}% 0% ${(1 - open) * 50}% 0% round ${cornerRadius}px)`,\n            transform: `scale(${interpolate(open, [0, 1], [0.985, 1])})`,\n          }}\n        >\n          {isVideoSource(src) ? (\n            <Video\n              src={src}\n              muted\n              loop\n              style={{\n                ...getMediaObjectFitStyle(fit),\n                transform: `scale(${interpolate(push, [0, 1], [1.06, 1])})`,\n              }}\n            />\n          ) : (\n            <Img\n              src={src}\n              style={{\n                ...getMediaObjectFitStyle(fit),\n                transform: `scale(${interpolate(push, [0, 1], [1.06, 1])})`,\n              }}\n            />\n          )}\n          {/* Grade: a rim of light along the top, weight along the bottom */}\n          <div\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              background: `linear-gradient(180deg, ${accentColor}12, transparent 26%, transparent 72%, ${palette.shadow})`,\n              opacity: open,\n            }}\n          />\n        </div>\n\n        {caption ? (\n          <div\n            style={{\n              width: frameW,\n              height: captionBlock,\n              display: \"flex\",\n              alignItems: \"flex-end\",\n              color: palette.dim,\n              fontSize: 24 * u,\n              fontWeight: 500,\n              opacity: captionIn,\n              transform: `translateY(${(1 - captionIn) * 10 * u}px)`,\n            }}\n          >\n            {caption}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "blocks",
    "drive": "media",
    "tier": "advanced"
  }
}