feat(shorts): cinematic v2 visual design + synced karaoke captions

- Fix critical bug: scenes were stacked without <Sequence> — every segment
  rendered simultaneously, last scene covering the whole video (root cause
  of 'too simple/ugly' visual)
- Scene variety: kinetic word-by-word title (hook), karaoke caption card,
  big-number stat scene (auto-detected: $12B, 65%, 3.2M), CTA center
- Karaoke captions synced to TTS: edge-tts SentenceBoundary timing via new
  scripts/tts-words.py (mp3 + sentences.json per segment); active sentence
  bright, others dimmed
- Rich background per scene: 4 color palettes (indigo/pink, cyan/teal,
  purple/pink, orange/red), drifting orbs, dot grid, ken-burns motion
- Real fonts: @fontsource Inter 700 + Montserrat 800 (display/captions)
- Glass card captions, progress bar per-scene, safe zone 900x1400
- Word-boundary truncation for stat labels (no mid-word cuts)
- Perf kept: no blur/SVG-noise (software renderer); ~2 min per 32s short
This commit is contained in:
MythEclipse
2026-09-22 21:03:22 +07:00
parent c0849dde62
commit 1db00c4182
6 changed files with 479 additions and 158 deletions
+20
View File
@@ -10,6 +10,8 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.6.0", "@auth/prisma-adapter": "^1.6.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@remotion/cli": "^4.0.527", "@remotion/cli": "^4.0.527",
"@remotion/google-fonts": "^4.0.527", "@remotion/google-fonts": "^4.0.527",
@@ -1047,6 +1049,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@grammyjs/types": { "node_modules/@grammyjs/types": {
"version": "3.28.0", "version": "3.28.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz",
+2
View File
@@ -15,6 +15,8 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.6.0", "@auth/prisma-adapter": "^1.6.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@remotion/cli": "^4.0.527", "@remotion/cli": "^4.0.527",
"@remotion/google-fonts": "^4.0.527", "@remotion/google-fonts": "^4.0.527",
+39 -9
View File
@@ -17,7 +17,7 @@
*/ */
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import { existsSync, mkdirSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
import path from "path"; import path from "path";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -39,6 +39,12 @@ mkdirSync(SCRATCH, { recursive: true });
const REMOTION_ROOT = path.resolve(process.env.REMOTION_ROOT || "./src/remotion"); const REMOTION_ROOT = path.resolve(process.env.REMOTION_ROOT || "./src/remotion");
const REMOTION_ENTRY = path.join(REMOTION_ROOT, "index.tsx"); const REMOTION_ENTRY = path.join(REMOTION_ROOT, "index.tsx");
const TTS_PYTHON =
process.env.TTS_PYTHON ||
(existsSync("/home/code/.hermes/hermes-agent/venv/bin/python3")
? "/home/code/.hermes/hermes-agent/venv/bin/python3"
: "python3");
const TTS_WORDS_PY = path.resolve(process.env.TTS_WORDS_PY || "./scripts/tts-words.py");
function sh(cmd, args, opts = {}) { function sh(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { const r = spawnSync(cmd, args, {
@@ -54,11 +60,18 @@ function sh(cmd, args, opts = {}) {
function tts(text, outBase) { function tts(text, outBase) {
const out = `${outBase}.mp3`; const out = `${outBase}.mp3`;
sh(EDGE_TTS_BIN, ["--voice", VOICE, "--text", text, "--write-media", out], { const srt = `${outBase}.sentences.json`;
timeout: 60_000, sh(
env: { ...process.env }, TTS_PYTHON,
}); [TTS_WORDS_PY, text, VOICE, out, srt],
return { file: out, durationSec: ffprobeDuration(out) }; { timeout: 60_000, env: { ...process.env } }
);
let sentences = [];
try {
sentences = JSON.parse(readFileSync(srt, "utf-8"));
} catch { /* fallback: single sentence */ }
if (!sentences.length) sentences = [{ w: text, start: 0, end: 0 }];
return { file: out, durationSec: ffprobeDuration(out), sentences };
} }
function ffprobeDuration(file) { function ffprobeDuration(file) {
@@ -81,6 +94,23 @@ function ffprobeSize(file) {
function log(...a) { console.log(new Date().toISOString(), ...a); } function log(...a) { console.log(new Date().toISOString(), ...a); }
/** Truncate at a word boundary (never cut mid-word). */
function clipWords(s, max) {
if (s.length <= max) return s;
const cut = s.slice(0, max);
const sp = cut.lastIndexOf(" ");
return (sp > max * 0.5 ? cut.slice(0, sp) : cut).trim() + "…";
}
/** Detect a stat worth rendering as a big-number scene: $12B, 65%, 3.2M. */
function extractStatData(text) {
const m = text.match(/(\$?\d+(?:\.\d+)?\s?(?:[BMK]%|billion|million|%))/i);
if (!m) return null;
const rest = text.replace(m[0], "").replace(/^[^A-Za-z0-9]+/, "").trim();
if (rest.length < 5) return null;
return { value: m[1].trim(), label: clipWords(rest, 70) };
}
/** Build segment text list from a YoutubeScript's structured fields. */ /** Build segment text list from a YoutubeScript's structured fields. */
function buildSegments(script) { function buildSegments(script) {
const segs = []; const segs = [];
@@ -133,12 +163,12 @@ async function renderScript(script) {
} }
// 3. Remotion timeline (frames) // 3. Remotion timeline (frames)
const timeline = voiced.map((s, i) => ({ const timeline = voiced.map((s) => ({
text: s.text, text: s.text,
duration: Math.max(30, Math.round(s.durationSec * FPS)), duration: Math.max(30, Math.round(s.durationSec * FPS)),
size: baseSegs.length <= 4 ? 80 : 68,
label: s.label, label: s.label,
highlight: s.highlight || undefined, sentences: s.sentences || undefined,
stat: extractStatData(s.text),
})); }));
// 4. Render silent video via Remotion CLI (props = segments JSON) // 4. Render silent video via Remotion CLI (props = segments JSON)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""TTS with per-sentence timing (karaoke captions).
Usage: tts-words.py <text> <voice> <out.mp3> <out.sentences.json>
Writes mp3 + JSON [{w, start, end}] (seconds, relative to segment start).
Uses edge-tts SentenceBoundary stream events.
"""
import asyncio
import json
import sys
import edge_tts
async def main(text: str, voice: str, mp3_path: str, sentences_path: str) -> None:
comm = edge_tts.Communicate(text, voice)
sentences = []
with open(mp3_path, "wb") as f:
async for chunk in comm.stream():
if chunk.get("type") == "audio":
f.write(chunk.get("data", b""))
elif chunk.get("type") == "SentenceBoundary":
start = chunk.get("offset", 0) / 1e7
dur = chunk.get("duration", 0) / 1e7
sentences.append(
{"w": chunk.get("text", ""), "start": round(start, 3), "end": round(start + dur, 3)}
)
with open(sentences_path, "w") as f:
json.dump(sentences, f)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]))
+128 -40
View File
@@ -1,51 +1,145 @@
import React from "react"; import React from "react";
import { AbsoluteFill } from "remotion"; import { AbsoluteFill, Sequence } from "remotion";
import { CaptionLine, SvgBackground, ProgressBar, BrandTag } from "./parts"; import {
SvgBackground,
BrandTag,
ProgressBar,
GlassCard,
CaptionKaraoke,
KineticTitle,
StatCounter,
SlideMotion,
} from "./parts";
/** A single timed segment: one text card rendered for `duration` frames. */ /** Sentence timing (karaoke), relative to segment start. */
const Segment: React.FC<{ export type Sentence = { w: string; start: number; end: number };
export type Segment = {
text: string; text: string;
highlight?: string; duration: number; // frames
label?: string; label?: string;
size: number; sentences?: Sentence[];
/** optional explicit scene override */
scene?: "title" | "stat" | "caption" | "cta";
stat?: { value: string; label: string };
};
/** Truncate at a word boundary (never cut mid-word). */
function clipWords(s: string, max: number): string {
if (s.length <= max) return s;
const cut = s.slice(0, max);
const sp = cut.lastIndexOf(" ");
return (sp > max * 0.5 ? cut.slice(0, sp) : cut).trim() + "…";
}
/** Detect a big number/stat worth showing as a stat scene. */
function extractStat(text: string): { value: string; label: string } | null {
const m = text.match(/(\$?\d+(?:[.,]\d+)?\s?[BMTK%]?)\s+(?:million|billion|trillion|percent|people|users|x)?/i);
if (m && /\d/.test(m[1]) && /[$%]|\d\s?[BMTK]\b/i.test(m[1])) {
const rest = text.replace(m[0], "").trim();
const label = clipWords(rest, 60);
if (label) return { value: m[1].trim(), label };
}
if (/\$?\d+(\.\d+)?\s?(billion|B|%|million|M)\b/i.test(text)) {
const m2 = text.match(/(\$?\d+(?:\.\d+)?\s?(?:billion|B|%|million|M))/i);
if (m2) {
const rest = text.replace(m2[0], "").trim();
return { value: m2[1], label: clipWords(rest, 60) || "…" };
}
}
return null;
}
/** Pick a scene for this segment (variety — pattern interrupt every cut). */
function pickScene(seg: Segment, index: number): "title" | "stat" | "caption" | "cta" {
if (seg.scene) return seg.scene;
const label = (seg.label || "").toUpperCase();
if (label === "HOOK" || label === "ON SCREEN") return "title";
if (label === "CTA") return "cta";
if (seg.stat) return "stat";
const stat = extractStat(seg.text);
if (stat) return "stat";
if (index % 3 === 2) return "title";
return "caption";
}
/** One scene rendered for `duration` frames. */
const Scene: React.FC<{
seg: Segment;
index: number;
from: number; from: number;
duration: number; }> = ({ seg, index, from }) => {
}> = ({ text, highlight, label, size, from, duration }) => ( const { duration, label, text } = seg;
const scene = pickScene(seg, index);
const stat = seg.stat || (scene === "stat" ? extractStat(text) : null);
const sentences =
seg.sentences && seg.sentences.length
? seg.sentences
: [{ w: text, start: 0, end: duration / 30 }];
return (
<AbsoluteFill> <AbsoluteFill>
<SvgBackground /> <SvgBackground variant={index} />
{label ? <BrandTag label={label} /> : null} <SlideMotion>
<CaptionLine text={text} highlight={highlight} size={size} /> {label ? <BrandTag label={label} variant={index} /> : null}
<ProgressBar total={duration} />
{/* current time indicator */} {scene === "stat" && stat ? (
<StatCounter value={stat.value} label={stat.label} />
) : scene === "title" || scene === "cta" ? (
<>
<KineticTitle
text={text}
size={scene === "cta" ? 76 : 88}
position={scene === "cta" ? "center" : "top"}
accentIndex={scene === "cta" ? -1 : text.split(" ").length - 1}
/>
<GlassCard style={{ padding: "22px 28px" }}>
<CaptionKaraoke sentences={sentences} size={38} />
</GlassCard>
</>
) : (
<>
<KineticTitle text={titleFor(seg)} size={54} />
<GlassCard>
<CaptionKaraoke sentences={sentences} size={44} />
</GlassCard>
</>
)}
<ProgressBar total={duration} variant={index} />
{/* time code (subtle, right side) */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
right: "6%", right: "6%",
bottom: "7.5%", bottom: "6.4%",
color: "rgba(255,255,255,0.4)", color: "rgba(255,255,255,0.42)",
fontSize: 22, fontSize: 24,
fontFamily: "monospace", fontFamily: "monospace",
letterSpacing: "0.1em", letterSpacing: "0.1em",
}} }}
> >
{(from / 30).toFixed(2)}s {(from / 30).toFixed(1)}s
</div> </div>
</SlideMotion>
</AbsoluteFill> </AbsoluteFill>
); );
};
/** Composition props driven by the worker (JSON). */ /** Section headline above the caption card. */
export const ShortsVideo: React.FC<{ function titleFor(seg: Segment): string {
segments: { const label = (seg.label || "").toUpperCase();
text: string; const map: Record<string, string> = {
duration: number; // frames "THE STORY": "The story",
highlight?: string; "THE CONFLICT": "The twist",
label?: string; "THE INSIGHT": "The lesson",
size?: number; };
}[]; if (map[label]) return map[label];
from?: number; return label ? label.charAt(0) + label.slice(1).toLowerCase() : "";
duration?: number; }
}> = ({ segments }) => {
const baseSize = 68; /** Composition: each scene time-gated by Sequence (frame resets per scene). */
export const ShortsVideo: React.FC<{ segments: Segment[] }> = ({ segments }) => {
let acc = 0; let acc = 0;
return ( return (
<> <>
@@ -53,15 +147,9 @@ export const ShortsVideo: React.FC<{
const from = acc; const from = acc;
acc += s.duration; acc += s.duration;
return ( return (
<Segment <Sequence key={i} from={from} durationInFrames={s.duration}>
key={i} <Scene seg={s} index={i} from={from} />
text={s.text} </Sequence>
highlight={s.highlight}
label={s.label}
size={s.size ?? baseSize}
from={from}
duration={s.duration}
/>
); );
})} })}
</> </>
+242 -94
View File
@@ -4,134 +4,290 @@ import {
useCurrentFrame, useCurrentFrame,
useVideoConfig, useVideoConfig,
interpolate, interpolate,
Easing,
spring, spring,
} from "remotion"; } from "remotion";
import "@fontsource/inter/700.css";
import "@fontsource/montserrat/800.css";
/** Animated diagonal gradient background + soft floating blobs. */ /* ─────────────────────────────────────────────────────────────────────────
export const SvgBackground: React.FC = () => { Shorts design system.
Fonts: Inter 700 (captions/body), Montserrat 800 (display).
Safe zone: 900×1400 centered (universal TikTok/Reels/Shorts).
Perf rule: NO blur filters / SVG noise (software Chrome ~10x slower).
───────────────────────────────────────────────────────────────────────── */
export const FONT_DISPLAY = "Montserrat, Inter, system-ui, sans-serif";
export const FONT_BODY = "Inter, system-ui, sans-serif";
/** Per-segment background palette (variety between scenes). */
const PALETTES = [
{ base: "linear-gradient(155deg,#0b1020 0%,#141a33 45%,#1b1030 100%)", a1: "99,102,241", a2: "236,72,153" },
{ base: "linear-gradient(155deg,#0a1628 0%,#0e2a3a 45%,#0a1f2e 100%)", a1: "56,189,248", a2: "16,185,129" },
{ base: "linear-gradient(155deg,#160b20 0%,#2a1040 45%,#1b0a2e 100%)", a1: "168,85,247", a2: "244,114,182" },
{ base: "linear-gradient(155deg,#1a0f0b 0%,#2e1a12 45%,#21100a 100%)", a1: "251,146,60", a2: "239,68,68" },
];
/** Animated background: gradient + drifting pre-softened orbs + dot grid. */
export const SvgBackground: React.FC<{ variant?: number }> = ({ variant = 0 }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { width, height } = useVideoConfig(); const { width, height } = useVideoConfig();
const drift = (speed: number, amp: number) => const t = frame / 60;
Math.sin((frame / 60) * speed) * amp; const p = PALETTES[variant % PALETTES.length];
return ( return (
<AbsoluteFill style={{ background: "#05060a" }}> <AbsoluteFill style={{ background: p.base }}>
<AbsoluteFill
style={{
background: `linear-gradient(160deg, #0b1020 0%, #141a33 45%, #1b1030 100%)`,
}}
/>
{/* floating gradient blobs (radial gradients are pre-softened — no blur filter) */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
width: width * 0.9, width: width * 0.85,
height: width * 0.9, height: width * 0.85,
left: width * 0.1 + drift(1, 60), left: width * 0.08 + Math.sin(t * 0.9) * 70,
top: height * 0.05 + drift(0.7, 40), top: height * 0.06 + Math.sin(t * 0.7) * 50,
borderRadius: "50%", borderRadius: "50%",
background: background: `radial-gradient(circle, rgba(${p.a1},0.34) 0%, rgba(${p.a1},0.16) 42%, transparent 70%)`,
"radial-gradient(circle, rgba(99,102,241,0.35) 0%, rgba(99,102,241,0.18) 40%, transparent 68%)",
}} }}
/> />
<div <div
style={{ style={{
position: "absolute", position: "absolute",
width: width * 0.7, width: width * 0.65,
height: width * 0.7, height: width * 0.65,
right: -width * 0.15 + drift(1.3, 50), right: -width * 0.12 + Math.cos(t * 1.1) * 60,
bottom: height * 0.18 + drift(0.9, 30), bottom: height * 0.16 + Math.cos(t * 0.8) * 40,
borderRadius: "50%", borderRadius: "50%",
background: background: `radial-gradient(circle, rgba(${p.a2},0.30) 0%, rgba(${p.a2},0.13) 42%, transparent 70%)`,
"radial-gradient(circle, rgba(236,72,153,0.28) 0%, rgba(236,72,153,0.14) 40%, transparent 68%)", }}
/>
{/* faint dot grid */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: "radial-gradient(rgba(255,255,255,0.07) 1px, transparent 1px)",
backgroundSize: "64px 64px",
opacity: 0.5,
}} }}
/> />
</AbsoluteFill> </AbsoluteFill>
); );
}; };
/** Big bold caption line with spring-in + fade + word-by-word pop. */ /** Glass card (translucent, rounded, border) for caption blocks. */
export const CaptionLine: React.FC<{ export const GlassCard: React.FC<{
text: string; children: React.ReactNode;
highlight?: string; style?: React.CSSProperties;
size: number; }> = ({ children, style }) => {
}> = ({ text, highlight, size }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } }); const pop = spring({ frame, fps, config: { damping: 15, stiffness: 110 } });
const enter = interpolate(frame, [0, 12], [0, 1], { return (
extrapolateRight: "clamp", <div
}); style={{
position: "absolute",
left: "5%",
right: "5%",
bottom: "13%",
padding: "30px 32px",
borderRadius: 28,
background: "rgba(8,10,18,0.68)",
border: "1px solid rgba(255,255,255,0.14)",
boxShadow: "0 14px 44px rgba(0,0,0,0.4)",
transform: `translateY(${(1 - pop) * 26}px) scale(${0.97 + pop * 0.03})`,
...style,
}}
>
{children}
</div>
);
};
/** Karaoke caption: active sentence full-bright, others dimmed (synced to TTS). */
export const CaptionKaraoke: React.FC<{
sentences: { w: string; start: number; end: number }[];
size?: number;
}> = ({ sentences, size = 44 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const now = frame / fps;
let activeIdx = 0;
for (let i = 0; i < sentences.length; i++) {
if (now >= sentences[i].start) { activeIdx = i; break; }
}
const active = sentences[activeIdx];
const flash = active
? interpolate(now, [active.start, active.start + 0.12, active.start + 0.35], [1, 1.06, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 1;
return (
<div
style={{
fontFamily: FONT_BODY,
fontWeight: 700,
fontSize: size,
lineHeight: 1.34,
color: "#ffffff",
letterSpacing: "-0.01em",
textShadow: "0 2px 14px rgba(0,0,0,0.55)",
transform: `scale(${flash})`,
transformOrigin: "left center",
}}
>
{sentences.map((s, i) => (
<span key={i} style={{ opacity: i === activeIdx ? 1 : 0.4 }}>
{s.w}
{i < sentences.length - 1 ? " " : ""}
</span>
))}
</div>
);
};
/** Big display title with word-by-word kinetic entrance. */
export const KineticTitle: React.FC<{
text: string;
size?: number;
accentIndex?: number;
accentColor?: string;
position?: "top" | "center";
}> = ({ text, size = 88, accentIndex = -1, accentColor = "#a5b4fc", position = "top" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 13, stiffness: 100 } });
const words = text.split(" "); const words = text.split(" ");
const charDur = Math.max(2, Math.floor(fps * 0.13)); const dur = Math.max(1, Math.floor(fps * 0.15));
return ( return (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
left: "6%", left: "5%",
right: "6%", right: "5%",
top: "18%", top: position === "center" ? "30%" : "12%",
transform: `translateY(${(1 - pop) * 30}px) scale(${0.96 + pop * 0.04})`, transform: `translateY(${(1 - pop) * 40}px)`,
opacity: enter,
textAlign: "left",
}}
>
<div
style={{
color: "rgba(255,255,255,0.92)",
fontSize: size,
fontWeight: 800,
lineHeight: 1.12,
letterSpacing: "-0.02em",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
textShadow: "0 4px 24px rgba(0,0,0,0.45)",
}} }}
> >
{words.map((w, i) => ( {words.map((w, i) => (
<span <span
key={i} key={i}
style={{ style={{
opacity: interpolate(
frame,
[i * charDur, i * charDur + charDur * 0.6],
[0, 1],
{ extrapolateRight: "clamp" }
),
display: "inline-block", display: "inline-block",
marginRight: "0.28em", marginRight: "0.2em",
color: fontFamily: FONT_DISPLAY,
highlight && w.toLowerCase().includes(highlight.toLowerCase()) fontWeight: 800,
? "#a5b4fc" fontSize: size,
: undefined, lineHeight: 1.1,
letterSpacing: "-0.03em",
color: i === accentIndex ? accentColor : "#ffffff",
textShadow: "0 4px 24px rgba(0,0,0,0.5)",
opacity: interpolate(frame, [i * dur, i * dur + dur * 0.5], [0, 1], {
extrapolateRight: "clamp",
}),
transform: `scale(${interpolate(frame, [i * dur, i * dur + dur * 0.6], [0.78, 1], {
extrapolateRight: "clamp",
})})`,
}} }}
> >
{w} {w}
</span> </span>
))} ))}
</div> </div>
</div>
); );
}; };
/** Bottom progress bar that fills over the segment duration. */ /** Huge stat/number card (pattern interrupt for facts). */
export const ProgressBar: React.FC<{ total: number }> = ({ total }) => { export const StatCounter: React.FC<{
value: string;
label: string;
color?: string;
}> = ({ value, label, color = "#a5b4fc" }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const pct = interpolate(frame, [0, total], [0, 100], { const { fps } = useVideoConfig();
extrapolateRight: "clamp", const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
});
return ( return (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
left: "6%", left: "6%",
right: "6%", right: "6%",
bottom: "7%", top: "34%",
height: 7, textAlign: "center",
transform: `scale(${0.85 + pop * 0.15})`,
}}
>
<div
style={{
fontFamily: FONT_DISPLAY,
fontWeight: 800,
fontSize: 132,
color,
lineHeight: 1,
letterSpacing: "-0.04em",
textShadow: "0 6px 30px rgba(0,0,0,0.55)",
}}
>
{value}
</div>
<div
style={{
fontFamily: FONT_BODY,
fontWeight: 700,
fontSize: 36,
color: "rgba(255,255,255,0.8)",
marginTop: 20,
lineHeight: 1.3,
}}
>
{label}
</div>
</div>
);
};
/** Top-left scene label pill. */
export const BrandTag: React.FC<{ label: string; variant?: number }> = ({ label, variant = 0 }) => {
const p = PALETTES[variant % PALETTES.length];
return (
<div
style={{
position: "absolute",
left: "6%",
top: "6%",
padding: "10px 22px",
borderRadius: 999,
background: `rgba(${p.a1},0.16)`,
border: `1px solid rgba(${p.a1},0.45)`,
color: `rgb(${p.a1})`,
fontSize: 30,
fontWeight: 700,
fontFamily: FONT_BODY,
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{label}
</div>
);
};
/** Bottom progress bar (per-segment fill). */
export const ProgressBar: React.FC<{ total: number; variant?: number }> = ({ total, variant = 0 }) => {
const frame = useCurrentFrame();
const p = PALETTES[variant % PALETTES.length];
const pct = interpolate(frame, [0, total], [0, 100], { extrapolateRight: "clamp" });
return (
<div
style={{
position: "absolute",
left: "6%",
right: "6%",
bottom: "6%",
height: 8,
borderRadius: 99, borderRadius: 99,
background: "rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.16)",
overflow: "hidden", overflow: "hidden",
}} }}
> >
@@ -140,31 +296,23 @@ export const ProgressBar: React.FC<{ total: number }> = ({ total }) => {
width: `${pct}%`, width: `${pct}%`,
height: "100%", height: "100%",
borderRadius: 99, borderRadius: 99,
background: "linear-gradient(90deg,#6366f1,#ec4899)", background: `linear-gradient(90deg, rgb(${p.a1}), rgb(${p.a2}))`,
}} }}
/> />
</div> </div>
); );
}; };
/** Small brand tag. */ /** Slow ken-burns style camera motion wrapper (transform only — cheap). */
export const BrandTag: React.FC<{ label: string }> = ({ label }) => ( export const SlideMotion: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<div const frame = useCurrentFrame();
style={{ const { fps } = useVideoConfig();
position: "absolute", const t = frame / fps;
left: "6%", const y = Math.sin(t * 1.4) * 14;
top: "7%", const scale = 1 + Math.sin(t * 0.8) * 0.014;
padding: "8px 18px", return (
borderRadius: 999, <AbsoluteFill style={{ transform: `translateY(${y}px) scale(${scale})` }}>
background: "rgba(255,255,255,0.08)", {children}
border: "1px solid rgba(255,255,255,0.16)", </AbsoluteFill>
color: "rgba(255,255,255,0.75)", );
fontSize: 26, };
fontWeight: 600,
letterSpacing: "0.06em",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
{label}
</div>
);