← Catalog
Edit Props (live)
config
config
config
Source
233 linesimport 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_captionKinetic 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)
wordsdefaultZonezoneTimeline
Meta
- Updated
- 4/21/2026