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:
Generated
+20
@@ -10,6 +10,8 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.6.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@remotion/cli": "^4.0.527",
|
||||
"@remotion/google-fonts": "^4.0.527",
|
||||
@@ -1047,6 +1049,24 @@
|
||||
"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": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.6.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@remotion/cli": "^4.0.527",
|
||||
"@remotion/google-fonts": "^4.0.527",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
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_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 = {}) {
|
||||
const r = spawnSync(cmd, args, {
|
||||
@@ -54,11 +60,18 @@ function sh(cmd, args, opts = {}) {
|
||||
|
||||
function tts(text, outBase) {
|
||||
const out = `${outBase}.mp3`;
|
||||
sh(EDGE_TTS_BIN, ["--voice", VOICE, "--text", text, "--write-media", out], {
|
||||
timeout: 60_000,
|
||||
env: { ...process.env },
|
||||
});
|
||||
return { file: out, durationSec: ffprobeDuration(out) };
|
||||
const srt = `${outBase}.sentences.json`;
|
||||
sh(
|
||||
TTS_PYTHON,
|
||||
[TTS_WORDS_PY, text, VOICE, out, srt],
|
||||
{ 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) {
|
||||
@@ -81,6 +94,23 @@ function ffprobeSize(file) {
|
||||
|
||||
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. */
|
||||
function buildSegments(script) {
|
||||
const segs = [];
|
||||
@@ -133,12 +163,12 @@ async function renderScript(script) {
|
||||
}
|
||||
|
||||
// 3. Remotion timeline (frames)
|
||||
const timeline = voiced.map((s, i) => ({
|
||||
const timeline = voiced.map((s) => ({
|
||||
text: s.text,
|
||||
duration: Math.max(30, Math.round(s.durationSec * FPS)),
|
||||
size: baseSegs.length <= 4 ? 80 : 68,
|
||||
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)
|
||||
|
||||
@@ -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]))
|
||||
+127
-39
@@ -1,51 +1,145 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { CaptionLine, SvgBackground, ProgressBar, BrandTag } from "./parts";
|
||||
import { AbsoluteFill, Sequence } from "remotion";
|
||||
import {
|
||||
SvgBackground,
|
||||
BrandTag,
|
||||
ProgressBar,
|
||||
GlassCard,
|
||||
CaptionKaraoke,
|
||||
KineticTitle,
|
||||
StatCounter,
|
||||
SlideMotion,
|
||||
} from "./parts";
|
||||
|
||||
/** A single timed segment: one text card rendered for `duration` frames. */
|
||||
const Segment: React.FC<{
|
||||
/** Sentence timing (karaoke), relative to segment start. */
|
||||
export type Sentence = { w: string; start: number; end: number };
|
||||
|
||||
export type Segment = {
|
||||
text: string;
|
||||
highlight?: string;
|
||||
duration: number; // frames
|
||||
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;
|
||||
duration: number;
|
||||
}> = ({ text, highlight, label, size, from, duration }) => (
|
||||
}> = ({ seg, index, from }) => {
|
||||
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>
|
||||
<SvgBackground />
|
||||
{label ? <BrandTag label={label} /> : null}
|
||||
<CaptionLine text={text} highlight={highlight} size={size} />
|
||||
<ProgressBar total={duration} />
|
||||
{/* current time indicator */}
|
||||
<SvgBackground variant={index} />
|
||||
<SlideMotion>
|
||||
{label ? <BrandTag label={label} variant={index} /> : null}
|
||||
|
||||
{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
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "6%",
|
||||
bottom: "7.5%",
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 22,
|
||||
bottom: "6.4%",
|
||||
color: "rgba(255,255,255,0.42)",
|
||||
fontSize: 24,
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
{(from / 30).toFixed(2)}s
|
||||
{(from / 30).toFixed(1)}s
|
||||
</div>
|
||||
</SlideMotion>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Composition props driven by the worker (JSON). */
|
||||
export const ShortsVideo: React.FC<{
|
||||
segments: {
|
||||
text: string;
|
||||
duration: number; // frames
|
||||
highlight?: string;
|
||||
label?: string;
|
||||
size?: number;
|
||||
}[];
|
||||
from?: number;
|
||||
duration?: number;
|
||||
}> = ({ segments }) => {
|
||||
const baseSize = 68;
|
||||
/** Section headline above the caption card. */
|
||||
function titleFor(seg: Segment): string {
|
||||
const label = (seg.label || "").toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
"THE STORY": "The story",
|
||||
"THE CONFLICT": "The twist",
|
||||
"THE INSIGHT": "The lesson",
|
||||
};
|
||||
if (map[label]) return map[label];
|
||||
return label ? label.charAt(0) + label.slice(1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Composition: each scene time-gated by Sequence (frame resets per scene). */
|
||||
export const ShortsVideo: React.FC<{ segments: Segment[] }> = ({ segments }) => {
|
||||
let acc = 0;
|
||||
return (
|
||||
<>
|
||||
@@ -53,15 +147,9 @@ export const ShortsVideo: React.FC<{
|
||||
const from = acc;
|
||||
acc += s.duration;
|
||||
return (
|
||||
<Segment
|
||||
key={i}
|
||||
text={s.text}
|
||||
highlight={s.highlight}
|
||||
label={s.label}
|
||||
size={s.size ?? baseSize}
|
||||
from={from}
|
||||
duration={s.duration}
|
||||
/>
|
||||
<Sequence key={i} from={from} durationInFrames={s.duration}>
|
||||
<Scene seg={s} index={i} from={from} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
+241
-93
@@ -4,134 +4,290 @@ import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
Easing,
|
||||
spring,
|
||||
} 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 { width, height } = useVideoConfig();
|
||||
const drift = (speed: number, amp: number) =>
|
||||
Math.sin((frame / 60) * speed) * amp;
|
||||
const t = frame / 60;
|
||||
const p = PALETTES[variant % PALETTES.length];
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#05060a" }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(160deg, #0b1020 0%, #141a33 45%, #1b1030 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* floating gradient blobs (radial gradients are pre-softened — no blur filter) */}
|
||||
<AbsoluteFill style={{ background: p.base }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: width * 0.9,
|
||||
height: width * 0.9,
|
||||
left: width * 0.1 + drift(1, 60),
|
||||
top: height * 0.05 + drift(0.7, 40),
|
||||
width: width * 0.85,
|
||||
height: width * 0.85,
|
||||
left: width * 0.08 + Math.sin(t * 0.9) * 70,
|
||||
top: height * 0.06 + Math.sin(t * 0.7) * 50,
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(99,102,241,0.35) 0%, rgba(99,102,241,0.18) 40%, transparent 68%)",
|
||||
background: `radial-gradient(circle, rgba(${p.a1},0.34) 0%, rgba(${p.a1},0.16) 42%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
right: -width * 0.15 + drift(1.3, 50),
|
||||
bottom: height * 0.18 + drift(0.9, 30),
|
||||
width: width * 0.65,
|
||||
height: width * 0.65,
|
||||
right: -width * 0.12 + Math.cos(t * 1.1) * 60,
|
||||
bottom: height * 0.16 + Math.cos(t * 0.8) * 40,
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(236,72,153,0.28) 0%, rgba(236,72,153,0.14) 40%, transparent 68%)",
|
||||
background: `radial-gradient(circle, rgba(${p.a2},0.30) 0%, rgba(${p.a2},0.13) 42%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
/** Big bold caption line with spring-in + fade + word-by-word pop. */
|
||||
export const CaptionLine: React.FC<{
|
||||
text: string;
|
||||
highlight?: string;
|
||||
size: number;
|
||||
}> = ({ text, highlight, size }) => {
|
||||
/** Glass card (translucent, rounded, border) for caption blocks. */
|
||||
export const GlassCard: React.FC<{
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}> = ({ children, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
|
||||
const enter = interpolate(frame, [0, 12], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const pop = spring({ frame, fps, config: { damping: 15, stiffness: 110 } });
|
||||
return (
|
||||
<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 charDur = Math.max(2, Math.floor(fps * 0.13));
|
||||
const dur = Math.max(1, Math.floor(fps * 0.15));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
right: "6%",
|
||||
top: "18%",
|
||||
transform: `translateY(${(1 - pop) * 30}px) scale(${0.96 + pop * 0.04})`,
|
||||
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)",
|
||||
left: "5%",
|
||||
right: "5%",
|
||||
top: position === "center" ? "30%" : "12%",
|
||||
transform: `translateY(${(1 - pop) * 40}px)`,
|
||||
}}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
opacity: interpolate(
|
||||
frame,
|
||||
[i * charDur, i * charDur + charDur * 0.6],
|
||||
[0, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
),
|
||||
display: "inline-block",
|
||||
marginRight: "0.28em",
|
||||
color:
|
||||
highlight && w.toLowerCase().includes(highlight.toLowerCase())
|
||||
? "#a5b4fc"
|
||||
: undefined,
|
||||
marginRight: "0.2em",
|
||||
fontFamily: FONT_DISPLAY,
|
||||
fontWeight: 800,
|
||||
fontSize: size,
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Bottom progress bar that fills over the segment duration. */
|
||||
export const ProgressBar: React.FC<{ total: number }> = ({ total }) => {
|
||||
/** Huge stat/number card (pattern interrupt for facts). */
|
||||
export const StatCounter: React.FC<{
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}> = ({ value, label, color = "#a5b4fc" }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const pct = interpolate(frame, [0, total], [0, 100], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
right: "6%",
|
||||
bottom: "7%",
|
||||
height: 7,
|
||||
top: "34%",
|
||||
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,
|
||||
background: "rgba(255,255,255,0.14)",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
@@ -140,31 +296,23 @@ export const ProgressBar: React.FC<{ total: number }> = ({ total }) => {
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 99,
|
||||
background: "linear-gradient(90deg,#6366f1,#ec4899)",
|
||||
background: `linear-gradient(90deg, rgb(${p.a1}), rgb(${p.a2}))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Small brand tag. */
|
||||
export const BrandTag: React.FC<{ label: string }> = ({ label }) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "6%",
|
||||
top: "7%",
|
||||
padding: "8px 18px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
border: "1px solid rgba(255,255,255,0.16)",
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
fontSize: 26,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
/** Slow ken-burns style camera motion wrapper (transform only — cheap). */
|
||||
export const SlideMotion: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
const y = Math.sin(t * 1.4) * 14;
|
||||
const scale = 1 + Math.sin(t * 0.8) * 0.014;
|
||||
return (
|
||||
<AbsoluteFill style={{ transform: `translateY(${y}px) scale(${scale})` }}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user