From a992d73edaeb54ee520933fd907667752d433a56 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 22 Sep 2026 22:38:30 +0700 Subject: [PATCH] =?UTF-8?q?feat(shorts):=20creative=20scenes=20=E2=80=94?= =?UTF-8?q?=20terminal=20hook,=20DON'T/DO=20compare,=20bullets,=20bang;=20?= =?UTF-8?q?MiniKaraoke=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/remotion/ShortsVideo.tsx | 69 +++++++- src/remotion/parts.tsx | 325 +++++++++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+), 2 deletions(-) diff --git a/src/remotion/ShortsVideo.tsx b/src/remotion/ShortsVideo.tsx index 38da25c..5400f97 100644 --- a/src/remotion/ShortsVideo.tsx +++ b/src/remotion/ShortsVideo.tsx @@ -9,6 +9,11 @@ import { KineticTitle, StatCounter, SlideMotion, + TerminalScene, + CompareScene, + BulletScene, + BangScene, + MiniKaraoke, } from "./parts"; import { TweetCard, ThreadCard, NewsCard } from "./cards"; @@ -21,7 +26,7 @@ export type Segment = { label?: string; sentences?: Sentence[]; /** optional explicit scene override */ - scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news"; + scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news" | "terminal" | "compare" | "fix" | "bullets" | "bang"; stat?: { value: string; label: string }; /** source content (tweet/thread/news) — rendered as a visual card scene */ source?: { @@ -72,12 +77,18 @@ function extractStat(text: string): { value: string; label: string } | null { function pickScene(seg: Segment, index: number): Segment["scene"] | "caption" { if (seg.scene) return seg.scene; const label = (seg.label || "").toUpperCase(); - if (label === "HOOK" || label === "ON SCREEN") return "title"; + // AI-tips scenes: terminal hook, mistake→compare(✗), insight→bullet list, + // fix→compare(✓), bang for conflict. + if (label === "THE MISTAKE" || label === "WHY IT FAILS") return "compare"; + if (label === "THE FIX") return "fix"; + if (label === "THE INSIGHT") return "bullets"; + if (label === "HOOK") return "terminal"; if (label === "CTA") return "cta"; if (seg.source) return seg.source.type as "tweet" | "thread" | "news"; if (seg.stat) return "stat"; const stat = extractStat(seg.text); if (stat) return "stat"; + if (label === "THE CONFLICT") return "bang"; if (index % 3 === 2) return "title"; return "caption"; } @@ -97,6 +108,60 @@ const Scene: React.FC<{ : [{ w: text, start: 0, end: duration / 30 }]; const src = seg.source; + // creative scenes: big visual + compact caption strip below + if (scene === "terminal") { + return ( + + + + {label ? : null} + + + + + + ); + } + if (scene === "compare" || scene === "fix") { + return ( + + + + {label ? : null} + + + + + + ); + } + if (scene === "bullets") { + return ( + + + + {label ? : null} + + + + + + ); + } + if (scene === "bang") { + return ( + + + + {label ? : null} + + + + + + ); + } + return ( diff --git a/src/remotion/parts.tsx b/src/remotion/parts.tsx index 73f2b4b..74de2bd 100644 --- a/src/remotion/parts.tsx +++ b/src/remotion/parts.tsx @@ -355,4 +355,329 @@ export const SlideMotion: React.FC<{ children: React.ReactNode }> = ({ children {children} ); +}; + +/* ═══════════════════════════════════════════════════════════════════════ + CREATIVE SCENES — visual variety beyond the caption card. + All monochrome flat, transform/opacity only (no blur/SVG filters). + ═══════════════════════════════════════════════════════════════════════ */ + +/** Monospace terminal typeface. */ +const FONT_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + +/** Line icons drawn with strokes (animate in via opacity/offset). */ +export const LineIcon: React.FC<{ + kind: "check" | "cross" | "node" | "bolt"; + size?: number; + delay?: number; +}> = ({ kind, size = 64, delay = 0 }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const pop = spring({ frame: frame - delay, fps, config: { damping: 16, stiffness: 120 } }); + const s = size; + const stroke = "currentColor"; + return ( + + {kind === "check" && ( + <> + + + + )} + {kind === "cross" && ( + <> + + + + )} + {kind === "node" && ( + <> + + + + + + )} + {kind === "bolt" && ( + + )} + + ); +}; + +/** Terminal scene — "$" prompt + typed commands, blinking cursor (AI-agent vibe). */ +export const TerminalScene: React.FC<{ text: string; size?: number }> = ({ text, size = 40 }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + // Split into command line(s) + output lines (roughly 4-6 words each) + const words = text.split(" "); + const lines: string[] = []; + let cur = ""; + for (const w of words) { + if ((cur + " " + w).trim().split(" ").length > 5) { lines.push(cur.trim()); cur = w; } + else cur = (cur + " " + w).trim(); + } + if (cur) lines.push(cur.trim()); + const cmd = lines[0] || "$ agent --fix"; + const output = lines.slice(1); + // typing effect for command + const typeDur = Math.max(6, Math.floor(fps * 0.8)); // frames to type full command + const chars = Math.min(cmd.length, Math.floor((frame / typeDur) * cmd.length)); + const typed = cmd.slice(0, chars); + const blink = frame % (fps * 1.1) < fps * 0.55; + const pop = spring({ frame, fps, config: { damping: 15, stiffness: 100 } }); + return ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ $ + {typed} + ▌ +
+ {output.map((l, i) => { + const o = Math.max(0, Math.min(1, (frame - typeDur - i * 10) / 12)); + return ( +
+ {l} +
+ ); + })} +
+ ); +}; + +/** DON'T (✗) vs DO (✓) — split compare panel. */ +export const CompareScene: React.FC<{ text: string; side: "bad" | "good"; size?: number }> = ({ text, side, size = 38 }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const pop = spring({ frame, fps, config: { damping: 15, stiffness: 100 } }); + const words = text.split(" "); + const chunk = Math.max(3, Math.ceil(words.length / 2)); + const halves = [words.slice(0, chunk).join(" "), words.slice(chunk).join(" ")]; + const good = side === "good"; + return ( +
+
+ +
+ DON'T +
+
+
→
+
+ +
+ DO THIS +
+
+
+
+ ); +}; + +/** Numbered bullet list (for insights: "1. one idea 2. …") — animated per bullet. */ +export const BulletScene: React.FC<{ text: string; size?: number }> = ({ text, size = 40 }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const words = text.split(" "); + const per = Math.max(3, Math.ceil(words.length / 3)); + const bullets = [ + words.slice(0, per).join(" "), + words.slice(per, per * 2).join(" "), + words.slice(per * 2).join(" "), + ].filter(Boolean); + return ( +
+ {bullets.map((b, i) => { + const start = i * 10; + const o = Math.max(0, Math.min(1, (frame - start) / 12)); + const y = (1 - o) * 30; + return ( +
+
+ {i + 1} +
+
+ {b} +
+
+ ); + })} +
+ ); +}; + +/** Bang scene — one keyword bursts center-stage with radial rays (the twist). */ +export const BangScene: React.FC<{ text: string }> = ({ text }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const words = text.split(" "); + const keyword = words.length > 4 ? words.slice(0, 3).join(" ") : text; + const rest = words.length > 4 ? words.slice(3).join(" ") : ""; + const boom = spring({ frame, fps, config: { damping: 12, stiffness: 90 } }); + const rays = 12; + return ( +
+ {/* radial rays */} + + {Array.from({ length: rays }).map((_, i) => { + const a = (i / rays) * Math.PI * 2; + const x1 = 280 + Math.cos(a) * 90; + const y1 = 280 + Math.sin(a) * 90; + const x2 = 280 + Math.cos(a) * (240 + (i % 3) * 20); + const y2 = 280 + Math.sin(a) * (240 + (i % 3) * 20); + return ; + })} + +
+ {keyword} +
+ {rest && ( +
+ {rest} +
+ )} +
+ ); +}; + +/** Compact karaoke caption strip — subtitle support below the big visual. */ +export const MiniKaraoke: React.FC<{ + sentences: { w: string; start: number; end: number }[]; + size?: number; +}> = ({ sentences, size = 26 }) => { + 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; } + } + return ( +
+ {sentences.map((s, i) => ( + + {s.w} + {i < sentences.length - 1 ? " " : ""} + + ))} +
+ ); }; \ No newline at end of file