feat(shorts): 11 reusable animated assets (charts, ring, gauge, ticker, countdown, nodeflow, pulse, checklist, HUD, stat) + label-aware asset picker

This commit is contained in:
asepharyana
2026-09-23 00:27:58 +07:00
parent 20ecbf1f98
commit 2c92ec3bff
2 changed files with 417 additions and 18 deletions
+108 -17
View File
@@ -16,6 +16,95 @@ import {
MiniKaraoke,
} from "./parts";
import { TweetCard, ThreadCard, NewsCard } from "./cards";
import {
ChartBars,
ChartLine,
RingProgress,
Gauge,
TickerTape,
Countdown,
NodeFlow,
PulseBars,
Checklist,
HudFrame,
StatBlock,
} from "./assets";
/** Asset pool — a visual building block sits above the caption card on
* regular caption scenes, chosen deterministically per (index, seed) so
* every video looks different. Reused across scenes via asset picker. */
const ASSET_POOL = [
"chartbars",
"chartline",
"ring",
"gauge",
"ticker",
"countdown",
"nodeflow",
"pulse",
"checklist",
"hud",
"stat",
] as const;
type AssetKey = (typeof ASSET_POOL)[number];
const AssetDecor: React.FC<{ index: number; seed: number; text: string; label: string }> = ({ index, seed = 0, text, label }) => {
// skip asset on tiny/labeled scenes? no — variety is the point; but CTA/HOOK
// already have big visuals so only attach to caption-ish scenes by intent.
const L = (label || "").toUpperCase();
if (["CTA", "HOOK", "ON SCREEN"].includes(L)) return null;
// Label-aware asset choice: THE STORY → growth charts, THE CONFLICT → bang
// energy (pulse/ring), THE INSIGHT → checklist/nodeflow. Falls back to the
// seeded pool for anything else, so videos vary regardless.
let key: AssetKey;
if (L === "THE STORY") {
key = (["chartbars", "chartline", "ticker", "stat"] as AssetKey[])[(index + seed) % 4];
} else if (L === "THE CONFLICT") {
key = (["pulse", "ring", "gauge", "pulse"] as AssetKey[])[(index + seed) % 4];
} else if (L === "THE INSIGHT") {
key = (["checklist", "nodeflow", "stat", "checklist"] as AssetKey[])[(index + seed) % 4];
} else if (L === "THE MISTAKE" || L === "WHY IT FAILS") {
key = "pulse";
} else if (L === "THE FIX") {
key = "checklist";
} else {
key = ASSET_POOL[(index + seed) % ASSET_POOL.length];
}
const words = text.split(" ").length;
// only show asset that "fits" — for now deterministic pick is fine
const delay = (index + seed) % 6;
const render = () => {
switch (key) {
case "chartbars": return <ChartBars bars={5} delay={delay} />;
case "chartline": return <ChartLine delay={delay} />;
case "ring": return <RingProgress pct={0.55 + ((seed + index) % 4) * 0.12} delay={delay} />;
case "gauge": return <Gauge value={0.5 + ((seed + index) % 5) * 0.08} label={words > 5 ? "SIGNAL" : "GAUGE"} delay={delay} />;
case "ticker": return <TickerTape text={text.slice(0, 60)} />;
case "countdown": return <Countdown end={3} from={5 + (seed % 4)} delay={delay} />;
case "nodeflow": return <NodeFlow delay={delay} />;
case "pulse": return <PulseBars bars={12} delay={delay} />;
case "checklist": return <Checklist items={["PLAN", "BUILD", "SHIP"]} delay={delay} />;
case "hud": return <HudFrame label={words > 4 ? "SYSTEM" : "DATA"} />;
case "stat": return <StatBlock value={`${(seed % 8) + 3}${index % 2 ? "M" : "%"}`} sub="REACH" />;
default: return null;
}
};
return (
<div
style={{
position: "absolute",
left: "8%", right: "8%", top: "18%",
display: "flex", alignItems: "center", justifyContent: "center",
flexDirection: "column",
height: "36%",
width: "84%",
pointerEvents: "none",
}}
>
{render()}
</div>
);
};
/** Sentence timing (karaoke), relative to segment start. */
export type Sentence = { w: string; start: number; end: number };
@@ -163,6 +252,25 @@ const Scene: React.FC<{
</AbsoluteFill>
);
}
if (scene === "caption" || scene === "title" || scene === "cta") {
// regular scenes get an animated asset above the card (variety!)
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
{scene === "caption" ? (
<AssetDecor index={index} seed={seed} text={text} label={label || ""} />
) : null}
<KineticTitle text={titleFor(seg)} size={54} />
<GlassCard>
<CaptionKaraoke sentences={sentences} size={44} />
</GlassCard>
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
return (
<AbsoluteFill>
@@ -191,25 +299,8 @@ const Scene: React.FC<{
/>
) : 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} />
+308
View File
@@ -0,0 +1,308 @@
import React from "react";
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { FONT_DISPLAY, FONT_BODY } from "./parts";
/* ═══════════════════════════════════════════════════════════════════════
REUSABLE MONO ASSETS — animated visual building blocks.
All monochrome flat, transform/opacity only (no blur/SVG filters → fast
software render). Each is a self-contained component sized to fit the
"asset zone" of a scene (typically placed above the caption card).
PITFALL (verified): a flex-row container whose children use `flex:1`
collapses to width 0 when it is itself a horizontal flex item (intrinsic
width = sum of flex-basis:0 items = 0). Always give root wrappers an
explicit width so assets render regardless of parent layout.
═══════════════════════════════════════════════════════════════════════ */
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Growing bar chart — editorial "growth" visual. */
export const ChartBars: React.FC<{ bars?: number; delay?: number }> = ({ bars = 5, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const heights = [0.42, 0.66, 0.5, 0.82, 0.62, 0.92, 0.72, 0.55, 0.88, 0.6];
const MAX_H = 180;
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 18, height: MAX_H, width: "100%" }}>
{Array.from({ length: bars }).map((_, i) => {
const h = heights[(i + Math.floor(delay)) % heights.length];
const grow = interpolate(frame, [delay + i * 3, delay + i * 3 + 12], [0.05, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
const px = Math.max(2, Math.round(h * MAX_H * grow));
return (
<div
key={i}
style={{
flex: 1,
minWidth: 40,
height: px,
background: i % 2 === 0 ? "#ffffff" : "rgba(255,255,255,0.5)",
borderRadius: 6,
}}
/>
);
})}
</div>
);
};
/** Line chart that draws itself left→right (clip-width animation). */
export const ChartLine: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = interpolate(frame, [delay, delay + fps * 0.9], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<svg width={520} height={320} viewBox="0 0 520 320">
{[60, 140, 220].map((y) => (
<line key={y} x1={20} y1={y} x2={500} y2={y} stroke="rgba(255,255,255,0.15)" strokeWidth={1.5} />
))}
<path
d="M0 280 C 50 260, 90 200, 130 210 S 220 130, 260 150 S 350 60, 390 80 S 470 30, 520 40"
fill="none"
stroke="rgba(255,255,255,0.9)"
strokeWidth={5}
strokeLinecap="round"
/>
{/* reveal mask */}
<rect x={0} y={0} width={520 * (1 - p)} height={320} fill="#000" />
<circle cx={520} cy={40} r={7 + Math.sin(frame / 6) * 3} fill="#ffffff" opacity={p} />
</svg>
);
};
/** Ring/donut progress (percent). */
export const RingProgress: React.FC<{ pct?: number; size?: number; delay?: number }> = ({ pct = 0.72, size = 220, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 20, stiffness: 60 } });
const r = 80;
const c = 2 * Math.PI * r;
const filled = Math.max(0, Math.min(1, pct * p));
return (
<svg width={size} height={size} viewBox="0 0 200 200">
<circle cx={100} cy={100} r={r} fill="none" stroke="rgba(255,255,255,0.18)" strokeWidth={14} />
<circle
cx={100} cy={100} r={r} fill="none" stroke="#ffffff" strokeWidth={14} strokeLinecap="round"
strokeDasharray={`${c * filled} ${c}`}
transform="rotate(-90 100 100)"
/>
<text x={100} y={112} textAnchor="middle" fill="#fff" fontSize={52} fontFamily={FONT_DISPLAY} fontWeight={800}>
{Math.round(filled * 100)}
<tspan fontSize={30} fill="rgba(255,255,255,0.7)">%</tspan>
</text>
</svg>
);
};
/** Gauge with sweeping needle. */
export const Gauge: React.FC<{ value?: number; label?: string; delay?: number }> = ({ value = 0.62, label = "ACCURACY", delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const v = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 16, stiffness: 70 } });
const angle = -90 + 180 * value * v; // -90°..+90°
return (
<svg width={400} height={260} viewBox="0 0 400 260">
<path d="M 30 220 A 170 170 0 0 1 370 220" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={16} strokeLinecap="round" />
<path
d="M 30 220 A 170 170 0 0 1 370 220"
fill="none" stroke="#ffffff" strokeWidth={16} strokeLinecap="round"
pathLength={1} strokeDasharray={value * v} strokeDashoffset={0}
/>
<g transform={`rotate(${angle} 200 220)`}>
<line x1={200} y1={220} x2={200} y2={70} stroke="#ffffff" strokeWidth={6} strokeLinecap="round" />
<circle cx={200} cy={70} r={8} fill="#fff" />
</g>
<circle cx={200} cy={220} r={14} fill="#fff" />
<text x={200} y={150} textAnchor="middle" fill="#fff" fontSize={40} fontFamily={MONO}>
{Math.round(value * 100)}%
</text>
<text x={200} y={250} textAnchor="middle" fill="rgba(255,255,255,0.65)" fontSize={26} fontFamily={FONT_BODY} fontWeight={700} letterSpacing="0.2em">
{label}
</text>
</svg>
);
};
/** Scrolling ticker tape (endless marquee text). */
export const TickerTape: React.FC<{ text: string; speed?: number }> = ({ text, speed = 90 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
const offset = (t * speed) % 50; // percent
const doubled = `${text} • ${text} • ${text}`;
return (
<div style={{ overflow: "hidden", whiteSpace: "nowrap", borderTop: "2px solid rgba(255,255,255,0.5)", borderBottom: "2px solid rgba(255,255,255,0.5)", padding: "14px 0" }}>
<div
style={{
display: "inline-block",
transform: `translateX(-${offset}%)`,
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 36, letterSpacing: "0.06em",
color: "#fff", textTransform: "uppercase",
}}
>
{doubled}
{doubled}
</div>
</div>
);
};
/** Big countdown / counter (rolling digits). */
export const Countdown: React.FC<{ end?: number; from?: number; delay?: number }> = ({ end = 3, from = 5, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = (frame - delay) / fps;
const val = Math.max(end, Math.round(from - t));
return (
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 240, lineHeight: 1, color: "#fff" }}>{val}</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, letterSpacing: "0.3em", color: "rgba(255,255,255,0.6)", marginTop: 10 }}>SECONDS</div>
</div>
);
};
/** Node graph + data pulse traveling an edge (AI/workflow vibe). */
export const NodeFlow: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = (frame - delay) / fps;
const pulse = (t % 1.4) / 1.4;
const nodes = [
{ x: 60, y: 160 },
{ x: 200, y: 60 },
{ x: 200, y: 260 },
{ x: 350, y: 160 },
];
const edges = [
[0, 1],
[0, 2],
[1, 3],
[2, 3],
];
return (
<svg width={420} height={320} viewBox="0 0 420 320">
{edges.map(([a, b], i) => {
const x1 = nodes[a].x, y1 = nodes[a].y, x2 = nodes[b].x, y2 = nodes[b].y;
// pulse position
const px = x1 + (x2 - x1) * pulse;
const py = y1 + (y2 - y1) * pulse;
return (
<g key={i}>
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(255,255,255,0.3)" strokeWidth={3} />
<circle cx={px} cy={py} r={6} fill="#fff" opacity={0.9} />
</g>
);
})}
{nodes.map((n, i) => (
<g key={i}>
<rect x={n.x - 24} y={n.y - 24} width={48} height={48} rx={12} fill="none" stroke="#fff" strokeWidth={3} />
<circle cx={n.x} cy={n.y} r={5} fill="#fff" />
</g>
))}
</svg>
);
};
/** Pulse bars — audio-equalizer energy. */
export const PulseBars: React.FC<{ bars?: number; delay?: number }> = ({ bars = 12, delay = 0 }) => {
const frame = useCurrentFrame();
const t = (frame - delay) / 60;
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, height: 220 }}>
{Array.from({ length: bars }).map((_, i) => {
const base = 0.25 + 0.3 * Math.abs(Math.sin(i * 1.7 + t * 4));
return (
<div
key={i}
style={{
width: 16, height: `${base * 100}%`, background: "#ffffff", borderRadius: 4,
transform: `scaleY(${0.4 + 0.6 * Math.abs(Math.sin(i * 1.3 + t * 3.2))})`,
transformOrigin: "center",
}}
/>
);
})}
</div>
);
};
/** Animated checklist — boxes tick one by one. */
export const Checklist: React.FC<{ items?: string[]; delay?: number }> = ({ items = ["PLAN", "BUILD", "SHIP"], delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{items.map((it, i) => {
const p = spring({ frame: Math.max(0, frame - delay - i * 14), fps, config: { damping: 16, stiffness: 100 } });
const done = p > 0.35;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 20, opacity: Math.max(0, Math.min(1, p * 1.4)) }}>
<div
style={{
width: 52, height: 52, borderRadius: 12,
border: "2px solid #fff",
display: "flex", alignItems: "center", justifyContent: "center",
background: done ? "#fff" : "transparent",
}}
>
{done && <svg width={30} height={30} viewBox="0 0 30 30"><path d="M7 16 L13 22 L23 9" stroke="#000" strokeWidth={5} fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 34, letterSpacing: "0.08em", color: "#fff" }}>
{it}
</div>
</div>
);
})}
</div>
);
};
/** HUD corner frame + crosshair center (tech overlay asset). */
export const HudFrame: React.FC<{ label?: string }> = ({ label = "SYSTEM" }) => {
const frame = useCurrentFrame();
const t = frame / 60;
const c = 36;
const gap = 26;
return (
<svg width={700} height={440} viewBox="0 0 700 440">
{[
`M ${gap} ${gap} L ${gap + c} ${gap} L ${gap + c} ${gap + c / 2} M ${gap} ${gap} L ${gap} ${gap + c} L ${gap + c / 2} ${gap + c}`,
`M ${700 - gap} ${gap} L ${700 - gap - c} ${gap} L ${700 - gap - c} ${gap + c / 2} M ${700 - gap} ${gap} L ${700 - gap} ${gap + c} L ${700 - gap - c / 2} ${gap + c}`,
`M ${gap} ${440 - gap} L ${gap + c} ${440 - gap} L ${gap + c} ${440 - gap - c / 2} M ${gap} ${440 - gap} L ${gap} ${440 - gap - c} L ${gap + c / 2} ${440 - gap - c}`,
`M ${700 - gap} ${440 - gap} L ${700 - gap - c} ${440 - gap} L ${700 - gap - c} ${440 - gap - c / 2} M ${700 - gap} ${440 - gap} L ${700 - gap} ${440 - gap - c} L ${700 - gap - c / 2} ${440 - gap - c}`,
].map((d, i) => (
<path key={i} d={d} fill="none" stroke="rgba(255,255,255,0.8)" strokeWidth={3} />
))}
{/* scanning line */}
<line x1={60} y1={60 + ((t * 90) % 320)} x2={640} y2={60 + ((t * 90) % 320)} stroke="rgba(255,255,255,0.25)" strokeWidth={1.5} />
{/* center crosshair */}
<circle cx={350} cy={220} r={40 + Math.sin(t * 2) * 6} fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth={2} />
<line x1={340} y1={220} x2={360} y2={220} stroke="#fff" strokeWidth={3} />
<line x1={350} y1={210} x2={350} y2={230} stroke="#fff" strokeWidth={3} />
<text x={350} y={412} textAnchor="middle" fill="rgba(255,255,255,0.7)" fontSize={26} fontFamily={MONO} letterSpacing="0.3em">
{label}
</text>
</svg>
);
};
/** Percent stat block — big value + thin bar (editorial). */
export const StatBlock: React.FC<{ value: string; sub: string; delay?: number }> = ({ value, sub, delay = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 14, stiffness: 80 } });
return (
<div style={{ textAlign: "center" }}>
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 170, lineHeight: 1, color: "#fff", transform: `scale(${0.8 + pop * 0.2})` }}>
{value}
</div>
<div style={{ margin: "18px auto 0", width: "70%", height: 8, borderRadius: 99, background: "rgba(255,255,255,0.2)", overflow: "hidden" }}>
<div style={{ width: `${pop * 100}%`, height: "100%", background: "#fff", borderRadius: 99 }} />
</div>
<div style={{ fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, color: "rgba(255,255,255,0.8)", marginTop: 18, textTransform: "uppercase", letterSpacing: "0.12em" }}>
{sub}
</div>
</div>
);
};