Catalog

Edit Props (live)

IMAGE
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config
config

Source

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

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

type TopMode = "scroll" | "static" | "logo_reveal" | "cursor_interact" | "fullscreen";

interface SplitHeadProps {
  topMode: TopMode;
  topSrc?: string;            // screenshot or long list asset
  topLogo?: string;           // logo image (for logo_reveal)
  topWordmark?: string;       // wordmark text (for logo_reveal)
  topTilt?: number;           // static tilt degrees, default -2
  topScrollAmt?: number;      // override scroll speed (0..1)
  cursorTarget?: { x: number; y: number }; // 0..1 normalized, for cursor_interact
  backgroundColor?: string;
  // Optional brand-theme overrides for logo_reveal top-mode
  topBackgroundColor?: string;  // overrides top panel bg (e.g. "#0d0d0f" for Anthropic)
  topAccentColor?: string;      // overrides ambient glow + accent tint
  topWordmarkColor?: string;    // overrides the wordmark text color
  captionText?: string;
  captionStyle?: "chip" | "magenta_outline";
  headSrc?: string | null;    // talking-head video
  headStartFrame?: number;    // seek-into for rach_head.mp4 so lips sync with audio
}

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

const isVideo = (s: string) => /\.(mp4|mov|webm)(\?|$)/i.test(s);

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

export const SplitHead: React.FC<SplitHeadProps> = ({
  topMode,
  topSrc,
  topLogo,
  topWordmark,
  topTilt = -2,
  topScrollAmt,
  cursorTarget = { x: 0.5, y: 0.6 },
  backgroundColor = "#f6f4ed",
  topBackgroundColor,
  topAccentColor,
  topWordmarkColor,
  captionText,
  captionStyle = "chip",
  headSrc,
  headStartFrame,
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames, width, height } = useVideoConfig();

  const captionOp = interpolate(frame, [10, 22], [0, 1], { extrapolateRight: "clamp" });
  const captionY = interpolate(frame, [10, 22], [12, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // "fullscreen" top mode takes over the whole frame; no head, no seam.
  if (topMode === "fullscreen" && topSrc) {
    return (
      <AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
        <FullscreenTop src={topSrc} backgroundColor={backgroundColor} />
        {captionText && (
          <div
            style={{
              position: "absolute",
              bottom: 180,
              left: 0,
              right: 0,
              opacity: captionOp,
              transform: `translateY(${captionY}px)`,
              display: "flex",
              justifyContent: "center",
              zIndex: 20,
              pointerEvents: "none",
            }}
          >
            <CaptionChip text={captionText} variant={captionStyle} />
          </div>
        )}
      </AbsoluteFill>
    );
  }

  return (
    <AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
      {/* Top half */}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          height: "50%",
          overflow: "hidden",
          background: backgroundColor,
        }}
      >
        {topMode === "scroll" && topSrc && <ScrollingTop src={topSrc} override={topScrollAmt} />}
        {topMode === "static" && topSrc && <StaticTop src={topSrc} tilt={topTilt} />}
        {topMode === "logo_reveal" && (
          <LogoRevealTop
            logo={topLogo}
            wordmark={topWordmark}
            backgroundColor={topBackgroundColor}
            accentColor={topAccentColor}
            wordmarkColor={topWordmarkColor}
          />
        )}
        {topMode === "cursor_interact" && topSrc && (
          <CursorInteractTop src={topSrc} target={cursorTarget} />
        )}
      </div>

      {/* Bottom half — talking head */}
      <div
        style={{
          position: "absolute",
          bottom: 0,
          left: 0,
          right: 0,
          height: "50%",
          overflow: "hidden",
          background: "#1a0b24",
        }}
      >
        {headSrc ? (
          isVideo(headSrc) ? (
            <OffthreadVideo
              src={resolveStatic(headSrc)}
              muted
              startFrom={headStartFrame}
              style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
            />
          ) : (
            <Img
              src={resolveStatic(headSrc)}
              style={{
                width: "100%", height: "100%",
                objectFit: "cover", objectPosition: "center -25px",
                display: "block",
              }}
            />
          )
        ) : (
          <HeadPlaceholder />
        )}
      </div>

      {/* Seam divider glow */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: 0,
          right: 0,
          height: 3,
          transform: "translateY(-1.5px)",
          background: "rgba(255,255,255,0.18)",
          boxShadow: "0 0 14px rgba(255,255,255,0.28)",
          pointerEvents: "none",
          zIndex: 8,
        }}
      />

      {/* Caption — sits at the seam */}
      {captionText && (
        <div
          style={{
            position: "absolute",
            top: "50%",
            left: 0,
            right: 0,
            transform: `translateY(calc(-50% + ${captionY}px))`,
            opacity: captionOp,
            display: "flex",
            justifyContent: "center",
            zIndex: 20,
            pointerEvents: "none",
          }}
        >
          <CaptionChip text={captionText} variant={captionStyle} />
        </div>
      )}
    </AbsoluteFill>
  );
};

// ─── Top modes ────────────────────────────────────────────────

const ScrollingTop: React.FC<{ src: string; override?: number }> = ({ src, override }) => {
  const frame = useCurrentFrame();
  const { durationInFrames, width, height } = useVideoConfig();
  // We render the source PNG at the SPLIT-HALF width (1080), so its natural
  // height = (imgH/imgW) * 1080. We must know the image's intrinsic ratio
  // before scaling — but at render time we don't have it. Instead, we render
  // the image at width=100% and read its DOM height via inline style. Since
  // <Img> here scales by width to 100% of container (1080), we use a wrapper
  // that translates the image upward by `scroll` pixels. The image's natural
  // height is preserved when width is fixed.
  const halfH = height / 2; // top half of the 9:16 frame (e.g. 960)

  // Easing-style accelerating sweep. We want to move FAST to sell motion.
  const amt = override ??
    interpolate(frame, [4, durationInFrames - 4], [0, 1], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.inOut(Easing.cubic),
    });
  const resolved = resolveStatic(src);
  const opIn = 1; // load instantly — no fade-in

  return (
    <div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#0a0612", opacity: opIn }}>
      <Img
        src={resolved}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          width: "100%",
          height: "auto",
          display: "block",
          // translateY relative to wrapper height (halfH). The image renders
          // at width 1080 → its natural height. We translate by -amt * (imgH - halfH).
          // We don't have imgH at compile time, so use a CSS calc with the
          // scroll factor expressed as a percentage of the IMAGE's own height
          // via translate(0, -(amt*(imgH-halfH))). For unknown imgH, use
          // calc + a generous distance: shift by amt * (-100% + halfH px).
          // (-100% means "shift up by image's own height", so adding halfH
          //  back keeps the bottom edge at the container bottom at amt=1.)
          transform: `translateY(calc(${(-amt * 100).toFixed(2)}% + ${(amt * halfH).toFixed(2)}px))`,
          willChange: "transform",
        }}
      />
      {/* Bottom fade into seam */}
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          bottom: 0,
          height: 80,
          background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.45))",
          pointerEvents: "none",
          zIndex: 2,
        }}
      />
      {/* Top fade for seamless edge */}
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          top: 0,
          height: 60,
          background: "linear-gradient(to top, transparent, rgba(0,0,0,0.55))",
          pointerEvents: "none",
          zIndex: 2,
        }}
      />
    </div>
  );
};

const StaticTop: React.FC<{ src: string; tilt: number }> = ({ src, tilt }) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();
  // Asset loads instantly — no fade-in so the bg doesn't flash through
  // before the screenshot appears.
  const opIn = 1;
  // Slow ken-burns on a full-bleed image so there's always motion + full coverage
  const progress = frame / Math.max(1, durationInFrames - 1);
  const scale = 1.05 + progress * 0.08;
  const driftX = Math.sin((frame / fps) * 0.6) * 1.5;
  const resolved = resolveStatic(src);
  const video = isVideo(src);
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        opacity: opIn,
        background: "#000",
      }}
    >
      {video ? (
        <OffthreadVideo
          src={resolved}
          muted
          style={{
            width: "100%",
            height: "100%",
            objectFit: "contain",
            display: "block",
            background: "#f6f4ed",
            transform: `scale(${scale}) translateX(${driftX}%) rotate(${tilt * 0.3}deg)`,
          }}
        />
      ) : (
        <Img
          src={resolved}
          style={{
            width: "100%",
            height: "100%",
            objectFit: "contain",
            display: "block",
            background: "#f6f4ed",
            transform: `scale(${scale}) translateX(${driftX}%) rotate(${tilt * 0.3}deg)`,
          }}
        />
      )}
      {/* Top gradient for polish */}
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          bottom: 0,
          height: 80,
          background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.4))",
          pointerEvents: "none",
        }}
      />
    </div>
  );
};

// Full-frame asset — used when a 16:9 video should headline the scene without the
// split. The video is laid out as a true 16:9 panel anchored toward the upper third
// of the 9:16 frame (eye level for phone viewing) so the bottom-aligned kinetic
// captions don't overlap it.
const FullscreenTop: React.FC<{ src: string; backgroundColor: string }> = ({ src, backgroundColor }) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames, width, height } = useVideoConfig();
  const opIn = 1; // load instantly — no fade-in
  const progress = frame / Math.max(1, durationInFrames - 1);
  const scale = 1.02 + progress * 0.04;
  const driftX = Math.sin((frame / fps) * 0.6) * 0.6;
  const resolved = resolveStatic(src);
  const video = isVideo(src);
  // 16:9 panel sized to the canvas width, parked so it ends ~10px above the
  // centered body-caption band (caption is at top:50% with translateY(-50%)).
  const panelWidth = width;
  const panelHeight = Math.round(panelWidth * 9 / 16);
  const panelTop = Math.round(height * 0.15);
  return (
    <div style={{ position: "absolute", inset: 0, overflow: "hidden", background: backgroundColor }}>
      <div
        style={{
          position: "absolute",
          top: panelTop,
          left: 0,
          width: panelWidth,
          height: panelHeight,
          opacity: opIn,
          background: "#000",
          overflow: "hidden",
          transform: `scale(${scale}) translateX(${driftX}%)`,
          transformOrigin: "center center",
        }}
      >
        {video ? (
          <OffthreadVideo
            src={resolved}
            muted
            style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
          />
        ) : (
          <Img
            src={resolved}
            style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
          />
        )}
      </div>
    </div>
  );
};

// Parse "#RRGGBB" → "r, g, b" for use inside rgba().
const hexToRgb = (hex: string): string => {
  const clean = hex.replace("#", "");
  const full = clean.length === 3
    ? clean.split("").map((c) => c + c).join("")
    : clean;
  const n = parseInt(full, 16);
  if (Number.isNaN(n)) return "120, 90, 255";
  return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
};

const LogoRevealTop: React.FC<{
  logo?: string;
  wordmark?: string;
  backgroundColor?: string;
  accentColor?: string;
  wordmarkColor?: string;
}> = ({ logo, wordmark, backgroundColor, accentColor, wordmarkColor }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const logoSpring = spring({
    frame,
    fps,
    config: { mass: 1.1, damping: 12, stiffness: 130 },
  });
  const logoScale = interpolate(logoSpring, [0, 1], [0.4, 1]);
  const logoOp = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });

  const wordStart = 12;
  const wordChars = wordmark?.split("") || [];
  const wordCps = 18;
  const revealCount = Math.max(0, Math.floor(((frame - wordStart) / fps) * wordCps));
  const revealed = wordChars.slice(0, revealCount).join("");

  const glowPulse = 0.7 + Math.sin((frame / fps) * 2.5) * 0.15;

  // Wordmark font size scales with character count so it fits width ≈ 700px
  const wm = wordmark || "";
  const wordFontSize = wm.length > 0 ? Math.min(160, Math.floor(720 / Math.max(4, wm.length)) + 40) : 140;

  const bg = backgroundColor || "#f6f4ed";
  const accent = accentColor || "#7a5aff";
  const wordColor = wordmarkColor || (backgroundColor ? "#ffffff" : "#111");
  const accentRgb = hexToRgb(accent);
  const bgRgb = hexToRgb(bg);

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        flexDirection: "column",
        gap: 28,
        padding: 40,
        background:
          `radial-gradient(ellipse at 50% 40%, rgba(${accentRgb}, 0.18) 0%, rgba(${bgRgb}, 1) 70%)`,
      }}
    >
      {/* Accent glow behind */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          width: 900,
          height: 900,
          transform: "translate(-50%, -50%)",
          background: `radial-gradient(circle, rgba(${accentRgb}, ${0.22 * glowPulse}) 0%, transparent 60%)`,
          pointerEvents: "none",
          filter: "blur(50px)",
        }}
      />
      {logo && (
        <Img
          src={resolveStatic(logo)}
          style={{
            width: 280,
            height: 280,
            objectFit: "contain",
            borderRadius: 56,
            transform: `scale(${logoScale})`,
            opacity: logoOp,
            filter: "drop-shadow(0 12px 30px rgba(0,0,0,0.45))",
          }}
        />
      )}
      {wordmark && (
        <div
          style={{
            fontFamily: FONT,
            fontSize: wordFontSize,
            fontWeight: 900,
            color: wordColor,
            letterSpacing: -3,
            opacity: logoOp,
            lineHeight: 1,
            textAlign: "center",
          }}
        >
          {revealed}
          <span
            style={{
              opacity: revealCount < wordChars.length ? (frame % 20 < 10 ? 1 : 0) : 0,
              fontWeight: 400,
            }}
          >
            |
          </span>
        </div>
      )}
    </div>
  );
};

const CursorInteractTop: React.FC<{ src: string; target: { x: number; y: number } }> = ({
  src,
  target,
}) => {
  const frame = useCurrentFrame();
  const resolved = resolveStatic(src);

  // Cursor glides from top-right to target, then clicks
  const glideT = interpolate(frame, [4, 32], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const startX = 0.92;
  const startY = 0.08;
  const cursorX = startX + (target.x - startX) * glideT;
  const cursorY = startY + (target.y - startY) * glideT;

  const clickFrame = 34;
  const clickT = interpolate(frame, [clickFrame, clickFrame + 22], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const clickScale = 0.8 + clickT * 2.6;
  const clickOp = interpolate(clickT, [0, 0.35, 1], [0.75, 0.75, 0]);

  const shotOp = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: "clamp" });

  // Slight parallax zoom toward the cursor target to suggest "interaction focus"
  const zoom = 1 + interpolate(frame, [0, 60], [0, 0.04], { extrapolateRight: "clamp" });
  const transformOrigin = `${target.x * 100}% ${target.y * 100}%`;

  return (
    <div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#0a0a14" }}>
      <Img
        src={resolved}
        style={{
          width: "100%",
          height: "100%",
          objectFit: "cover",
          objectPosition: "center center",
          opacity: shotOp,
          transform: `scale(${zoom})`,
          transformOrigin,
        }}
      />
      {/* Halo pulse under target (visible before click lands) */}
      <div
        style={{
          position: "absolute",
          left: `${target.x * 100}%`,
          top: `${target.y * 100}%`,
          width: 120,
          height: 120,
          marginLeft: -60,
          marginTop: -60,
          borderRadius: "50%",
          background: "radial-gradient(circle, rgba(80,154,219,0.35) 0%, transparent 70%)",
          opacity: interpolate(frame, [20, 32], [0, 0.9], { extrapolateRight: "clamp" }),
          pointerEvents: "none",
          filter: "blur(6px)",
        }}
      />
      {/* Click ripple */}
      <div
        style={{
          position: "absolute",
          left: `${target.x * 100}%`,
          top: `${target.y * 100}%`,
          width: 100,
          height: 100,
          marginLeft: -50,
          marginTop: -50,
          borderRadius: "50%",
          border: "3px solid rgba(80, 154, 219, 0.95)",
          transform: `scale(${clickScale})`,
          opacity: clickOp,
          pointerEvents: "none",
        }}
      />
      {/* Cursor */}
      <svg
        width="46"
        height="60"
        viewBox="0 0 34 44"
        style={{
          position: "absolute",
          left: `${cursorX * 100}%`,
          top: `${cursorY * 100}%`,
          transform: "translate(-6px, -3px)",
          filter: "drop-shadow(0 3px 5px rgba(0,0,0,0.55))",
          zIndex: 5,
        }}
      >
        <path
          d="M4 4 L4 32 L11 26 L16 38 L20 36 L15 24 L24 24 Z"
          fill="#fff"
          stroke="#000"
          strokeWidth="1.5"
        />
      </svg>
    </div>
  );
};

const HeadPlaceholder: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const pulse = 0.6 + Math.sin((frame / fps) * 1.2) * 0.08;
  // Ambient moody gradient — no text so the empty frame reads as "studio lighting" not "missing asset"
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background:
          "radial-gradient(ellipse at 50% 45%, rgba(140, 90, 220, 0.45) 0%, rgba(20, 8, 30, 1) 65%)",
      }}
    >
      {/* Soft light columns suggesting purple LED behind a creator */}
      <div
        style={{
          position: "absolute",
          left: "12%",
          top: "8%",
          bottom: "8%",
          width: 14,
          background: `linear-gradient(to bottom, rgba(180,120,255,${0.5 * pulse}) 0%, rgba(180,120,255,${0.2 * pulse}) 100%)`,
          filter: "blur(18px)",
          borderRadius: 10,
        }}
      />
      <div
        style={{
          position: "absolute",
          right: "12%",
          top: "12%",
          bottom: "12%",
          width: 14,
          background: `linear-gradient(to bottom, rgba(180,120,255,${0.35 * pulse}) 0%, rgba(180,120,255,${0.5 * pulse}) 100%)`,
          filter: "blur(18px)",
          borderRadius: 10,
        }}
      />
      {/* Central orb soft bloom where the face would be */}
      <div
        style={{
          position: "absolute",
          top: "48%",
          left: "50%",
          transform: "translate(-50%, -50%)",
          width: 360,
          height: 360,
          borderRadius: "50%",
          background: `radial-gradient(circle, rgba(240,220,255,${0.18 * pulse}) 0%, transparent 70%)`,
          filter: "blur(30px)",
        }}
      />
    </div>
  );
};

const CaptionChip: React.FC<{ text: string; variant: "chip" | "magenta_outline" }> = ({
  text,
  variant,
}) => {
  if (variant === "magenta_outline") {
    return (
      <div
        style={{
          fontFamily: FONT,
          fontSize: 78,
          fontWeight: 900,
          color: "#ff3bd1",
          WebkitTextStroke: "4px #000",
          letterSpacing: -2,
          textShadow: "3px 3px 0 #000, -3px 3px 0 #000, 3px -3px 0 #000, -3px -3px 0 #000",
        }}
      >
        {text}
      </div>
    );
  }
  return (
    <div
      style={{
        background: "rgba(0,0,0,0.8)",
        color: "#fff",
        padding: "16px 28px",
        borderRadius: 14,
        fontFamily: FONT,
        fontSize: 46,
        fontWeight: 700,
        letterSpacing: -0.4,
      }}
    >
      {text}
    </div>
  );
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: SplitHead
// 16 props detected · 3 port(s)

Split Head

split_head

Split Head — Andy's hand-crafted Remotion scene. 16 props. topLogo:IMAGE, captionText:TEXT, captionStyle:TEXT.

Workflow Inputs (3)

  • Top Logo
    topLogo
    IMAGE
  • Caption Text
    captionText
    TEXT
  • Caption Style
    captionStyle
    TEXT

Config Fields (13)

  • y
  • topSrc
  • headSrc
  • topMode
  • topTilt
  • topWordmark
  • cursorTarget
  • topScrollAmt
  • headStartFrame
  • topAccentColor
  • backgroundColor
  • topWordmarkColor
  • topBackgroundColor

Meta

Updated
4/21/2026