From 2c92ec3bff91d9e57297add6d4c237d8112060b5 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 23 Sep 2026 00:27:58 +0700 Subject: [PATCH] feat(shorts): 11 reusable animated assets (charts, ring, gauge, ticker, countdown, nodeflow, pulse, checklist, HUD, stat) + label-aware asset picker --- src/remotion/ShortsVideo.tsx | 127 +++++++++++++-- src/remotion/assets.tsx | 308 +++++++++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+), 18 deletions(-) create mode 100644 src/remotion/assets.tsx diff --git a/src/remotion/ShortsVideo.tsx b/src/remotion/ShortsVideo.tsx index 15d9a9b..69d469a 100644 --- a/src/remotion/ShortsVideo.tsx +++ b/src/remotion/ShortsVideo.tsx @@ -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 ; + case "chartline": return ; + case "ring": return ; + case "gauge": return 5 ? "SIGNAL" : "GAUGE"} delay={delay} />; + case "ticker": return ; + case "countdown": return ; + case "nodeflow": return ; + case "pulse": return ; + case "checklist": return ; + case "hud": return 4 ? "SYSTEM" : "DATA"} />; + case "stat": return ; + default: return null; + } + }; + return ( +
+ {render()} +
+ ); +}; /** Sentence timing (karaoke), relative to segment start. */ export type Sentence = { w: string; start: number; end: number }; @@ -163,6 +252,25 @@ const Scene: React.FC<{ ); } + if (scene === "caption" || scene === "title" || scene === "cta") { + // regular scenes get an animated asset above the card (variety!) + return ( + + + + {label ? : null} + {scene === "caption" ? ( + + ) : null} + + + + + + + + ); + } return ( @@ -191,25 +299,8 @@ const Scene: React.FC<{ /> ) : scene === "stat" && stat ? ( - ) : scene === "title" || scene === "cta" ? ( - <> - - - - - ) : ( - <> - - - - - + )} diff --git a/src/remotion/assets.tsx b/src/remotion/assets.tsx new file mode 100644 index 0000000..97976f6 --- /dev/null +++ b/src/remotion/assets.tsx @@ -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 ( +
+ {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 ( +
+ ); + })} +
+ ); +}; + +/** 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 ( + + {[60, 140, 220].map((y) => ( + + ))} + + {/* reveal mask */} + + + + ); +}; + +/** 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 ( + + + + + {Math.round(filled * 100)} + % + + + ); +}; + +/** 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 ( + + + + + + + + + + {Math.round(value * 100)}% + + + {label} + + + ); +}; + +/** 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 ( +
+
+ {doubled} + {doubled} +
+
+ ); +}; + +/** 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 ( +
+
{val}
+
SECONDS
+
+ ); +}; + +/** 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 ( + + {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 ( + + + + + ); + })} + {nodes.map((n, i) => ( + + + + + ))} + + ); +}; + +/** 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 ( +
+ {Array.from({ length: bars }).map((_, i) => { + const base = 0.25 + 0.3 * Math.abs(Math.sin(i * 1.7 + t * 4)); + return ( +
+ ); + })} +
+ ); +}; + +/** 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 ( +
+ {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 ( +
+
+ {done && } +
+
+ {it} +
+
+ ); + })} +
+ ); +}; + +/** 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 ( + + {[ + `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) => ( + + ))} + {/* scanning line */} + + {/* center crosshair */} + + + + + {label} + + + ); +}; + +/** 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 ( +
+
+ {value} +
+
+
+
+
+ {sub} +
+
+ ); +}; \ No newline at end of file