/** * KineticTitleScene — Full-screen kinetic text card * * bgStyle="solid" — always-on dark background (default) * bgStyle="dynamic_panel" — bg wipes in before text, wipes out after text gone * bgStyle="transparent" — no background (composite over footage, render with alpha codec) * * ALL words land simultaneously (staggered by 4 frames each). * Alternating solid / outline / accent treatment. * Based on Higgsfield / SOUL 2.0 reference style. */ import React from "react"; import { useCurrentFrame, useVideoConfig, spring, interpolate, Easing } from "remotion"; import { loadFont as loadPoppins } from "@remotion/google-fonts/Poppins"; import { loadFont as loadHeebo } from "@remotion/google-fonts/Heebo"; const { fontFamily: poppins } = loadPoppins(); const { fontFamily: heebo } = loadHeebo(); export interface KineticTitleProps { words: string[]; // 2–4 words, each on its own line subtext?: string; // optional small line below accentColor?: string; // default brand blue language?: string; // "en" | "he" bgStyle?: "solid" | "dynamic_panel" | "transparent"; durationInFrames: number; fps: number; } const BRAND_BLUE = "#60a5fa"; const STAGGER = 4; // frames between each word entrance // How many frames the bg leads the text on entry, and trails it on exit const BG_LEAD = 10; // bg starts wiping in 10 frames before text const BG_TRAIL = 12; // bg stays for 12 frames after text is gone export const KineticTitleScene: React.FC = ({ words = ["WENT", "VIRAL"], subtext, accentColor = BRAND_BLUE, language = "en", bgStyle = "solid", durationInFrames = 150, fps = 30, }) => { const frame = useCurrentFrame(); const { width, height } = useVideoConfig(); const isHebrew = language === "he" || language === "iw"; const fontFamily = isHebrew ? heebo : poppins; const isVertical = height > width; const accent = accentColor; const isDynamic = bgStyle === "dynamic_panel"; const isTransparent = bgStyle === "transparent"; // ── Text timing ────────────────────────────────────────────────────────── // Dynamic: text starts after bg has partially appeared const textOffset = isDynamic ? BG_LEAD : 0; const textExitStart = durationInFrames - (isDynamic ? BG_TRAIL + 12 : 12); const exitOpacity = interpolate( frame, [textExitStart, textExitStart + 12], [1, 0], { extrapolateRight: "clamp" } ); // Global entrance scale const globalScale = interpolate(frame - textOffset, [0, 20], [1.04, 1.0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic), }); // ── Background panel timing ─────────────────────────────────────────────── // BG enters: wipes left→right starting at frame 0 const bgEnterDuration = 18; const bgEnterProgress = interpolate(frame, [0, bgEnterDuration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic), }); // BG exits: wipes left→right starting after text is fully gone const bgExitStart = textExitStart + 12; const bgExitProgress = interpolate( frame, [bgExitStart, bgExitStart + 14], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.in(Easing.cubic), } ); // scaleX wipe — most reliable in Remotion headless Chrome // Enter: scaleX grows 0→1 from LEFT origin (reveals left to right) // Exit: scaleX shrinks 1→0 from RIGHT origin (removes left to right) const bgScaleX = isDynamic ? (frame < bgExitStart ? interpolate(bgEnterProgress, [0, 1], [0, 1]) : interpolate(bgExitProgress, [0, 1], [1, 0])) : 1; const bgTransformOrigin = isDynamic && frame >= bgExitStart ? "right center" : "left center"; const showBg = !isTransparent; // ── Accent scan line across bg (cinematic HUD feel) ────────────────────── const scanY = interpolate(frame, [0, durationInFrames], [0, height], { extrapolateRight: "clamp", }); const scanOpacity = isDynamic ? bgEnterProgress * (1 - bgExitProgress) * 0.12 : 0.06; // ── Font size ───────────────────────────────────────────────────────────── const baseFontSize = isVertical ? Math.round(width * 0.22) : Math.round(height * 0.26); // ── Word styles: solid → outline → accent ──────────────────────────────── const wordStyles = (i: number) => { const mod = i % 3; if (mod === 0) return { color: "#ffffff", stroke: "none", shadow: `0 0 40px ${accent}44` }; if (mod === 1) return { color: "transparent", stroke: accent, shadow: `0 0 30px ${accent}44` }; return { color: accent, stroke: "none", shadow: `0 0 50px ${accent}88` }; }; // ── Separator line ──────────────────────────────────────────────────────── const SepLine = ({ delay }: { delay: number }) => { const lineW = interpolate(frame - delay, [0, 16], [0, 1], { extrapolateRight: "clamp", easing: Easing.out(Easing.cubic), }); return (
); }; return (
{/* ── Background panel (with clip-path wipe) ── */} {showBg && (
{/* Dark fill */}
{/* Grid lines */}
{/* Diagonal corner accent */}
{/* Vignette */}
{/* Horizontal scan line (slow drift) */}
{/* Bright edge line at wipe front — sits at the right edge of the scaled panel */} {isDynamic && bgScaleX > 0 && bgScaleX < 1 && (
)}
)} {/* ── Word stack ── */}
{words.map((word, i) => { const delay = textOffset + i * STAGGER; const wordScale = spring({ frame: Math.max(0, frame - delay), fps, from: 0, to: 1, config: { damping: 9, stiffness: 350 }, }); const wordY = interpolate( frame - delay, [0, 14], [40, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) } ); const ws = wordStyles(i); const glitchAmt = i === 0 ? interpolate(frame - textOffset, [0, 1, 3], [12, 4, 0], { extrapolateRight: "clamp" }) : 0; return ( {i > 0 && }
{glitchAmt > 0 && (
{word}
)}
{word}
); })} {subtext && (() => { const stDelay = textOffset + words.length * STAGGER + 10; const stOpacity = interpolate(frame - stDelay, [0, 14], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); return (
{subtext}
); })()}
{/* ── Viewfinder corners ── */} {Math.floor(frame / 10) % 2 === 0 && [ { top: 24, left: 24, borderTopWidth: 2, borderLeftWidth: 2 }, { top: 24, right: 24, borderTopWidth: 2, borderRightWidth: 2 }, { bottom: 24, left: 24, borderBottomWidth: 2, borderLeftWidth: 2 }, { bottom: 24, right: 24, borderBottomWidth: 2, borderRightWidth: 2 }, ].map((pos, i) => (
))} {/* ── Frame counter ── */}
{String(frame).padStart(4, "0")}
); };