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,
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+241
-93
@@ -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>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user