Catalog

Edit Props (live)

config
config
config

Source

233 lines
import React from "react";
import {
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { ZONE_Y, type CaptionZone, type ZoneWindow } from "./captionZones";
// ZoneWindow is now { start, end, yFraction }. yFraction=-1 means "skip".

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

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

interface WordTimestamp { word: string; start: number; end: number; emphasis?: boolean; }
interface KineticCaptionProps {
  words: WordTimestamp[];
  zoneTimeline?: ZoneWindow[];
  defaultZone?: CaptionZone;
}

// Flow captions (pill chip) for every phrase — hook style disabled.
const HOOK_WORD_COUNT = 0;
const FRAME_W = 1080;

function resolveYFraction(t: number, timeline: ZoneWindow[] | undefined, fallback: CaptionZone): number {
  if (!timeline || timeline.length === 0) return ZONE_Y[fallback];
  for (let i = 0; i < timeline.length; i++) {
    const z = timeline[i];
    if (t >= z.start && t < z.end) return z.yFraction;
  }
  return ZONE_Y[fallback];
}

export const KineticCaption: React.FC<KineticCaptionProps> = ({
  words,
  zoneTimeline,
  defaultZone = "bottom",
}) => {
  const frame = useCurrentFrame();
  const { fps, height } = useVideoConfig();
  const t = frame / fps;

  const phrases = groupIntoPhrases(words);

  let pidx = -1;
  for (let i = 0; i < phrases.length; i++) {
    const displayEnd = i + 1 < phrases.length ? phrases[i + 1].start : phrases[i].end;
    if (t >= phrases[i].start && t < displayEnd) { pidx = i; break; }
  }
  if (pidx === -1) return null;

  let wSoFar = 0;
  for (let i = 0; i <= pidx; i++) wSoFar += phrases[i].words.length;
  const isHook = wSoFar <= HOOK_WORD_COUNT;

  const cur = phrases[pidx];

  // A phrase's display extends from its start until the NEXT phrase begins,
  // so a phrase from scene N can bleed into scene N+1. If the CURRENT frame
  // falls in a zone="none" scene, hide the caption regardless of which
  // phrase owns it — chart/quote/tweet scenes should never be overlaid.
  const currentZoneY = resolveYFraction(t, zoneTimeline, defaultZone);
  if (currentZoneY < 0) return null;

  // For positioning, use the phrase's midpoint — that way the chip stays
  // anchored to one zone even if the phrase straddles a scene boundary and
  // the current frame's zone differs mid-phrase.
  const phraseMid = (cur.start + cur.end) / 2;
  const yFraction = resolveYFraction(phraseMid, zoneTimeline, defaultZone);
  if (yFraction < 0) return null;
  const centerYpx = yFraction * height;

  if (isHook) {
    const vis: { word: string; op: number }[] = [];
    for (const wt of cur.wordTimestamps) {
      if (t < wt.start) break;
      vis.push({ word: wt.word, op: Math.min((t - wt.start) * 12, 1) });
    }
    if (vis.length === 0) return null;
    const isP1 = pidx === 0;

    const w1sz = isP1 ? 280 : 200;
    const w1w = 900;
    const w1ls = isP1 ? -11.2 : -8;

    const wRsz = isP1 ? 200 : 100;
    const wRw = 400;
    const wRls = isP1 ? -8 : -4;

    const margin = 80;
    const maxBlockW = FRAME_W - margin * 2;
    const charRatioBold = 0.62;
    const charRatioLight = 0.55;

    const allWords = cur.wordTimestamps.map(wt => wt.word);
    const estW1 = allWords[0].length * w1sz * charRatioBold;
    const fullRestText = allWords.slice(1).join(" ");
    const estWR = fullRestText.length * wRsz * charRatioLight;

    const worstWidth = Math.max(estW1, estWR);
    const scale = worstWidth > maxBlockW ? maxBlockW / worstWidth : 1;
    const w1szFit = Math.floor(w1sz * scale);
    const w1lsFit = w1ls * scale;
    const wRszFit = Math.floor(wRsz * scale);
    const wRlsFit = wRls * scale;
    const gap = Math.floor(28 * scale);

    // Anchor hook block's TOP near the zone Y (hook reads as a big opening).
    const hookTop = Math.max(60, Math.round(centerYpx - (w1szFit + wRszFit) * 0.75));

    return (
      <div style={{
        position: "absolute",
        top: hookTop, left: margin, right: margin, zIndex: 50,
      }}>
        <div style={{
          color: "white", fontSize: w1szFit, fontWeight: w1w,
          fontFamily: FONT, fontStyle: "italic",
          letterSpacing: w1lsFit, lineHeight: "1em",
          opacity: vis[0].op,
          textShadow: "0 4px 12px rgba(0,0,0,0.55)",
        }}>
          {vis[0].word}
        </div>
        {vis.length > 1 && (
          <div style={{
            display: "flex", gap, alignItems: "baseline",
            flexWrap: "wrap",
          }}>
            {vis.slice(1).map((w, i) => (
              <span key={i} style={{
                color: "white", fontSize: wRszFit, fontWeight: wRw,
                fontFamily: FONT, fontStyle: "italic",
                letterSpacing: wRlsFit, lineHeight: "1.2em",
                opacity: w.op,
                textShadow: "0 4px 12px rgba(0,0,0,0.55)",
              }}>
                {w.word}
              </span>
            ))}
          </div>
        )}
      </div>
    );
  }

  // FLOW: dark pill chip with bold white text, word-emphasis pop preserved.
  if (t < cur.start) return null;
  const phraseAge = t - cur.start;
  const phraseOp = Math.min(phraseAge * 14, 1);
  const phraseScale = Math.min(0.92 + phraseAge * 0.8, 1);

  const empCount = cur.wordTimestamps.filter(w => w.emphasis).length;
  const canPop = empCount === 1;

  return (
    <div style={{
      position: "absolute",
      top: centerYpx, left: 0, right: 0, zIndex: 50,
      transform: "translateY(-50%)",
      display: "flex", justifyContent: "center", padding: "0 50px",
      pointerEvents: "none",
    }}>
      <div style={{
        background: "#262626",
        borderRadius: 14,
        padding: "14px 28px",
        boxShadow: "0 12px 30px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.04) inset",
        display: "flex", flexWrap: "wrap", justifyContent: "center",
        gap: 12, alignItems: "baseline",
        opacity: phraseOp,
        transform: `scale(${phraseScale})`,
        transformOrigin: "center center",
      }}>
        {cur.wordTimestamps.map((wt, i) => {
          const emp = wt.emphasis === true;
          let empScale = 1;
          if (emp && canPop) {
            const popDur = 0.25;
            const pt = Math.min(phraseAge / popDur, 1);
            if (pt < 0.4) {
              empScale = 1 + (pt / 0.4) * 0.08;
            } else {
              empScale = 1.08 - ((pt - 0.4) / 0.6) * 0.08;
            }
          }
          return (
            <span key={i} style={{
              color: "white",
              fontSize: emp ? 58 : 50,
              fontWeight: emp ? 900 : 700,
              fontFamily: FONT, fontStyle: "italic",
              letterSpacing: emp ? -2.4 : -1.8,
              display: "inline-block",
              transform: canPop && emp ? `scale(${empScale})` : undefined,
              transformOrigin: "center baseline",
            }}>
              {wt.word}
            </span>
          );
        })}
      </div>
    </div>
  );
};

interface WTS { word: string; start: number; end: number; emphasis?: boolean; }
interface Phrase { text: string; words: string[]; wordTimestamps: WTS[]; start: number; end: number; }

function groupIntoPhrases(words: WordTimestamp[]): Phrase[] {
  if (!words.length) return [];
  const phrases: Phrase[] = [];
  let cur: WordTimestamp[] = [];
  for (let i = 0; i < words.length; i++) {
    const w = words[i];
    cur.push(w);
    const isLast = i === words.length - 1;
    const gap = !isLast ? words[i + 1].start - w.end : 999;
    const punct = /[.,!?;:]$/.test(w.word);
    if (isLast || punct || gap > 0.15 || cur.length >= 3) {
      phrases.push({
        text: cur.map(c => c.word).join(" "),
        words: cur.map(c => c.word),
        wordTimestamps: cur.map(c => ({ word: c.word, start: c.start, end: c.end, emphasis: c.emphasis })),
        start: cur[0].start, end: cur[cur.length - 1].end,
      });
      cur = [];
    }
  }
  return phrases;
}

Schema (Zod)

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

Kinetic Caption

kinetic_caption

Kinetic Caption — Andy's hand-crafted Remotion scene. 3 props. no ports detected — source-only preview.

Workflow Inputs (0)

This block has no workflow-connected inputs.

Config Fields (3)

  • words
  • defaultZone
  • zoneTimeline

Meta

Updated
4/21/2026