import React from "react"; import { AbsoluteFill, useCurrentFrame, spring, useVideoConfig } from "remotion"; import { FONT_BODY, FONT_DISPLAY } from "./parts"; /* ───────────────────────────────────────────────────────────────────────── Source-content cards — mono, flat, editorial. Rendered as scenes between/around TTS caption scenes so the video shows the actual tweet/thread/news, not just subtitles. ───────────────────────────────────────────────────────────────────────── */ const WHITE = "#ffffff"; const BLACK = "#000000"; const GREY = "#8a8a90"; function slug(s: string, n = 12): string { return (s || "").length > n ? s.slice(0, n - 1) + "…" : s; } function splitLines(text: string, perLine = 34): string[] { const words = (text || "").split(/\s+/).filter(Boolean); const lines: string[] = []; let cur = ""; for (const w of words) { if ((cur + " " + w).trim().length > perLine) { if (cur) lines.push(cur.trim()); cur = w; } else { cur = (cur + " " + w).trim(); } } if (cur) lines.push(cur.trim()); return lines.slice(0, 7); // cap } /** Staggered fade-up for card text blocks. */ function useStagger(total: number, step = 6) { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } }); return (i: number) => { const o = spring({ frame: frame - i * step, fps, config: { damping: 16, stiffness: 90 }, delay: 0 }); return { opacity: Math.max(0, Math.min(1, frame - i * step) / 8), transform: `translateY(${(1 - o) * 22}px)`, }; }; } /** ── Tweet / Thread card ─────────────────────────────────────────────── */ export const TweetCard: React.FC<{ handle: string; name: string; text: string; likes?: number | null; retweets?: number | null; replies?: number | null; views?: number | null; }> = ({ handle, name, text, likes, retweets, replies, views }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } }); const count = (v?: number | null) => (v == null ? null : formatCount(v)); return (
{/* header: avatar initial + name/handle */}
{(name || handle || "?")[0].toUpperCase()}
{slug(name, 22)}
@{handle}
{/* tweet body */}
{text}
{/* stats row */}
{count(views) !== null && ▶ {count(views)}} {count(replies) !== null && ↩ {count(replies)}} {count(retweets) !== null && ↻ {count(retweets)}} {count(likes) !== null && ♥ {count(likes)}}
); }; /** ── Thread card (multiple tweets stacked) ───────────────────────────── */ export const ThreadCard: React.FC<{ handle: string; tweets: { text: string; name?: string }[]; }> = ({ handle, tweets }) => { const anim = useStagger(tweets.length); return (
🧵 @{handle} · thread
{tweets.map((t, i) => (
{splitLines(t.text, 30).join("\n")}
))}
); }; /** ── News / article card ─────────────────────────────────────────────── */ export const NewsCard: React.FC<{ title: string; excerpt?: string; author?: string; source?: string; }> = ({ title, excerpt, author, source }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } }); const lines = splitLines(excerpt || title, 30); return (
{/* news kicker */}
{source || "News"}
{/* headline */}
{splitLines(title, 26).join("\n")}
{/* excerpt */}
{lines.join("\n")}
{/* footer */} {(author || source) && (
{author ? `— ${author}` : ""}
)}
); }; /** Helper: format numbers compactly (1234 → 1.2K). */ export function formatCount(n: number): string { if (n == null) return "0"; if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; return String(n); }