Catalog

Edit Props (live)

TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config

Source

303 lines
import React from "react";
import {
  AbsoluteFill,
  Img,
  OffthreadVideo,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  staticFile,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";

loadInter("normal", { weights: ["400", "500", "700", "900"] });

type InsetPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";

interface TalkingHeadInsetProps {
  assetSrc?: string;              // image or video shown as the corner inset
  headSrc?: string | null;        // talking-head .mov (transparent)
  insetPosition?: InsetPosition;  // default top-left (head sits bottom-right)
  insetScale?: number;            // width as fraction of frame (default 0.42)
  insetTilt?: number;             // degrees (default -3)
  insetAspect?: number;           // height/width override; otherwise auto from asset
  showChrome?: boolean;           // mac-style window chrome bar (default true)
  chromeLabel?: string;           // optional URL/title shown in chrome bar
  backgroundColor?: string;       // bg behind everything (default solid blue)
  headScale?: number;             // talking-head width as frac of frame (default 0.66)
  headPosition?: "right" | "left" | "center"; // default right
  headStartFrame?: number;        // seek the head video this many frames in (keeps sync w/ VO)
  captionText?: string;
  captionStyle?: "chip" | "magenta_outline";
}

const FONT = "'Inter', sans-serif";

const isVideo = (s: string) => /\.(mp4|mov|webm)(\?|$)/i.test(s);
const isImage = (s: string) => /\.(png|jpg|jpeg|webp|gif)(\?|$)/i.test(s);

const resolveStatic = (s: string) =>
  s.startsWith("http") ? s : staticFile(s);

export const TalkingHeadInset: React.FC<TalkingHeadInsetProps> = ({
  assetSrc,
  headSrc,
  insetPosition = "top-left",
  insetScale = 0.42,
  insetTilt = -3,
  insetAspect,
  showChrome = true,
  chromeLabel,
  backgroundColor = "#509ADB",
  headScale = 0.66,
  headPosition = "right",
  headStartFrame,
  captionText,
  captionStyle = "chip",
}) => {
  const frame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();

  // Inset entry: slight scale-up + fade over ~10 frames
  const insetOp = interpolate(frame, [0, 10], [0, 1], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const insetScaleAnim = interpolate(frame, [0, 14], [0.94, 1], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Caption fade-in
  const capOp = interpolate(frame, [8, 20], [0, 1], { extrapolateRight: "clamp" });
  const capY = interpolate(frame, [8, 20], [10, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Inset positioning — inset rect anchor in frame
  const insetW = width * insetScale;
  const insetH = insetW * (insetAspect ?? 0.62); // wide-screenshot default ~16:10
  const margin = width * 0.045;
  const chromeH = showChrome ? Math.round(insetW * 0.08) : 0;
  const totalH = insetH + chromeH;

  let leftPx: number;
  let topPx: number;
  switch (insetPosition) {
    case "top-right":
      leftPx = width - insetW - margin;
      topPx = margin + height * 0.08;
      break;
    case "bottom-left":
      leftPx = margin;
      topPx = height - totalH - margin - height * 0.18;
      break;
    case "bottom-right":
      leftPx = width - insetW - margin;
      topPx = height - totalH - margin - height * 0.18;
      break;
    case "top-left":
    default:
      leftPx = margin;
      topPx = margin + height * 0.08;
  }

  // Talking-head positioning — fills frame from bottom anchor
  const headW = width * headScale;
  const headH = headW; // square crop window — same as ReelVideo per-scene TH
  let headLeft: number;
  switch (headPosition) {
    case "left":
      headLeft = -headW * 0.1;
      break;
    case "center":
      headLeft = (width - headW) / 2;
      break;
    case "right":
    default:
      headLeft = width - headW * 0.95;
  }
  const headTop = height - headH;

  return (
    <AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
      {/* Subtle radial vignette behind everything for depth */}
      <AbsoluteFill
        style={{
          background:
            "radial-gradient(ellipse at center, rgba(255,255,255,0.06) 0%, transparent 60%)",
        }}
      />

      {/* Talking head — fills bottom-right of frame */}
      {headSrc && (
        <div
          style={{
            position: "absolute",
            left: headLeft,
            top: headTop,
            width: headW,
            height: headH,
            overflow: "hidden",
            zIndex: 5,
          }}
        >
          {isImage(headSrc) ? (
            <Img
              src={resolveStatic(`visuals/${headSrc}`)}
              style={{
                width: "100%",
                height: "100%",
                objectFit: "cover",
                objectPosition: "center center",
              }}
            />
          ) : (
            <OffthreadVideo
              src={resolveStatic(`visuals/${headSrc}`)}
              transparent
              muted
              startFrom={headStartFrame}
              style={{
                width: "100%",
                height: "100%",
                objectFit: "cover",
                objectPosition: "center center",
              }}
            />
          )}
        </div>
      )}

      {/* Inset card — asset with optional chrome, sits over the head */}
      {assetSrc && (
        <div
          style={{
            position: "absolute",
            left: leftPx,
            top: topPx,
            width: insetW,
            height: totalH,
            transform: `rotate(${insetTilt}deg) scale(${insetScaleAnim})`,
            transformOrigin: "center center",
            opacity: insetOp,
            borderRadius: 18,
            overflow: "hidden",
            boxShadow:
              "0 30px 70px rgba(0,0,0,0.45), 0 8px 24px rgba(0,0,0,0.3)",
            zIndex: 20,
            background: "#0a0a0a",
          }}
        >
          {showChrome && (
            <div
              style={{
                width: "100%",
                height: chromeH,
                background:
                  "linear-gradient(180deg, #2a2a2e 0%, #1d1d20 100%)",
                display: "flex",
                alignItems: "center",
                paddingLeft: chromeH * 0.5,
                gap: chromeH * 0.25,
                borderBottom: "1px solid rgba(0,0,0,0.4)",
              }}
            >
              <div style={dot("#FF5F57", chromeH)} />
              <div style={dot("#FEBC2E", chromeH)} />
              <div style={dot("#28C840", chromeH)} />
              {chromeLabel && (
                <div
                  style={{
                    flex: 1,
                    textAlign: "center",
                    color: "#aaa",
                    fontFamily: FONT,
                    fontSize: chromeH * 0.42,
                    fontWeight: 500,
                    paddingRight: chromeH * 1.5,
                    overflow: "hidden",
                    whiteSpace: "nowrap",
                    textOverflow: "ellipsis",
                  }}
                >
                  {chromeLabel}
                </div>
              )}
            </div>
          )}
          <div style={{ width: "100%", height: insetH, background: "#000" }}>
            {isVideo(assetSrc) ? (
              <OffthreadVideo
                src={resolveStatic(`visuals/${assetSrc}`)}
                muted
                style={{ width: "100%", height: "100%", objectFit: "cover" }}
              />
            ) : (
              <Img
                src={resolveStatic(`visuals/${assetSrc}`)}
                style={{ width: "100%", height: "100%", objectFit: "cover" }}
              />
            )}
          </div>
        </div>
      )}

      {/* Caption chip */}
      {captionText && (
        <div
          style={{
            position: "absolute",
            left: 0,
            right: 0,
            bottom: height * 0.08,
            display: "flex",
            justifyContent: "center",
            zIndex: 30,
            opacity: capOp,
            transform: `translateY(${capY}px)`,
          }}
        >
          <div
            style={
              captionStyle === "magenta_outline"
                ? {
                    fontFamily: FONT,
                    fontWeight: 900,
                    fontSize: 64,
                    color: "#fff",
                    WebkitTextStroke: "3px #ff2eb0",
                    letterSpacing: -1,
                    textTransform: "uppercase",
                    padding: "0 28px",
                  }
                : {
                    fontFamily: FONT,
                    fontWeight: 700,
                    fontSize: 46,
                    color: "#111",
                    background: "#fff",
                    padding: "14px 28px",
                    borderRadius: 14,
                    boxShadow: "0 8px 24px rgba(0,0,0,0.35)",
                    letterSpacing: -0.3,
                  }
            }
          >
            {captionText}
          </div>
        </div>
      )}
    </AbsoluteFill>
  );
};

const dot = (color: string, h: number): React.CSSProperties => ({
  width: h * 0.42,
  height: h * 0.42,
  borderRadius: "50%",
  background: color,
});

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: TalkingHeadInset
// 14 props detected · 2 port(s)

Talking Head Inset

talking_head_inset

Talking Head Inset — Andy's hand-crafted Remotion scene. 14 props. captionText:TEXT, captionStyle:TEXT.

Workflow Inputs (2)

  • Caption Text
    captionText
    TEXT
  • Caption Style
    captionStyle
    TEXT

Config Fields (12)

  • headSrc
  • assetSrc
  • headScale
  • insetTilt
  • insetScale
  • showChrome
  • chromeLabel
  • insetAspect
  • headPosition
  • insetPosition
  • headStartFrame
  • backgroundColor

Meta

Updated
4/21/2026