feat(shorts): 4 more scene types (quote, podium, gridlist, scoreboard) — 15 scene types total
Build & Deploy Hermy HQ (Nix + Attic) / build-and-deploy (push) Canceled after 0s
Build & Deploy Hermy HQ (Nix + Attic) / cleanup (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-23 08:36:17 +07:00
parent 3b019b2462
commit 4601f612a4
2 changed files with 214 additions and 1 deletions
+63 -1
View File
@@ -13,6 +13,10 @@ import {
CompareScene, CompareScene,
BulletScene, BulletScene,
BangScene, BangScene,
QuoteScene,
PodiumScene,
GridListScene,
ScoreboardScene,
MiniKaraoke, MiniKaraoke,
} from "./parts"; } from "./parts";
import { TweetCard, ThreadCard, NewsCard } from "./cards"; import { TweetCard, ThreadCard, NewsCard } from "./cards";
@@ -147,7 +151,7 @@ export type Segment = {
label?: string; label?: string;
sentences?: Sentence[]; sentences?: Sentence[];
/** optional explicit scene override */ /** optional explicit scene override */
scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news" | "terminal" | "compare" | "fix" | "bullets" | "bang"; scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news" | "terminal" | "compare" | "fix" | "bullets" | "bang" | "quote" | "podium" | "gridlist" | "scoreboard";
stat?: { value: string; label: string }; stat?: { value: string; label: string };
/** source content (tweet/thread/news) — rendered as a visual card scene */ /** source content (tweet/thread/news) — rendered as a visual card scene */
source?: { source?: {
@@ -211,6 +215,12 @@ function pickScene(seg: Segment, index: number, seed = 0): Segment["scene"] | "c
if (seg.stat) return "stat"; if (seg.stat) return "stat";
const stat = extractStat(seg.text); const stat = extractStat(seg.text);
if (stat) return "stat"; if (stat) return "stat";
// creative scene variety (round 2): quote/podium/grid/scoreboard rotation
const j = (index + seed) % 8;
if (j === 3) return "quote";
if (j === 5) return "podium";
if (j === 6) return "gridlist";
if (j === 7) return "scoreboard";
if (i % 3 === 2) return "title"; if (i % 3 === 2) return "title";
return "caption"; return "caption";
} }
@@ -284,6 +294,58 @@ const Scene: React.FC<{
</AbsoluteFill> </AbsoluteFill>
); );
} }
if (scene === "quote") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<QuoteScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "podium") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<PodiumScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "gridlist") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<GridListScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "scoreboard") {
return (
<AbsoluteFill>
<SvgBackground variant={index + (seed || 0)} />
<SlideMotion>
{label ? <BrandTag label={label} /> : null}
<ScoreboardScene text={text} />
<MiniKaraoke sentences={sentences} />
<ProgressBar total={duration} />
</SlideMotion>
</AbsoluteFill>
);
}
if (scene === "caption" || scene === "title" || scene === "cta") { if (scene === "caption" || scene === "title" || scene === "cta") {
// regular scenes get an animated asset above the card (variety!) // regular scenes get an animated asset above the card (variety!)
return ( return (
+151
View File
@@ -762,4 +762,155 @@ export const MiniKaraoke: React.FC<{
))} ))}
</div> </div>
); );
};
/* ═══════════════════════════════════════════════════════════════════════
SCENE ROUND 2 — 4 more creative scene types (mono flat).
═══════════════════════════════════════════════════════════════════════ */
/** Quote scene — oversized quotation mark + emphasized excerpt + attribution. */
export const QuoteScene: React.FC<{ text: string; size?: number }> = ({ text, size = 50 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 70 } });
const words = text.split(" ");
const quote = words.length > 8 ? words.slice(0, -3).join(" ") : text;
const byline = words.length > 8 ? words.slice(-3).join(" ") : "";
return (
<div style={{ position: "absolute", left: "10%", right: "10%", top: "24%", textAlign: "center" }}>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 190, lineHeight: 0.6,
color: "rgba(255,255,255,0.25)", transform: `translateY(${(1 - p) * 40}px)`,
}}
>
&ldquo;
</div>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size, lineHeight: 1.2,
letterSpacing: "-0.02em", color: "#ffffff", marginTop: -20,
opacity: p,
}}
>
{quote}
</div>
{byline && (
<div
style={{
marginTop: 26, display: "inline-block", borderTop: "2px solid rgba(255,255,255,0.6)",
paddingTop: 14, fontFamily: FONT_BODY, fontWeight: 700, fontSize: 26,
color: "rgba(255,255,255,0.75)", letterSpacing: "0.12em", textTransform: "uppercase",
opacity: p,
}}
>
— {byline}
</div>
)}
</div>
);
};
/** Podium scene — 1-2-3 columns with the middle one elevated (ranking). */
export const PodiumScene: React.FC<{ text: string; size?: number }> = ({ text, size = 36 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 80 } });
const words = text.split(" ");
const picks = words.length >= 3 ? [words[0], words[1], words[2]] : ["TOP", "3", "PICKS"];
const heights = [200, 280, 150];
return (
<div style={{ position: "absolute", left: 0, right: 0, top: "26%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 30 }}>
{picks.map((w, i) => (
<div key={i} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
<div
style={{
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size,
color: i === 1 ? "#000" : "#fff", textAlign: "center",
transform: `translateY(${(1 - p) * 80}px)`, opacity: p,
width: 210, lineHeight: 1.15,
}}
>
{w}
</div>
<div
style={{
width: 210, height: 70 + (heights[i] - 70) * p,
background: i === 1 ? "#fff" : "rgba(255,255,255,0.25)",
border: "2px solid #fff", borderRadius: 14,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 44, color: i === 1 ? "#000" : "#fff",
}}
>
{i + 1}
</div>
</div>
))}
</div>
);
};
/** Grid list — 2×2 keyword tiles popping in with check corners. */
export const GridListScene: React.FC<{ text: string; size?: number }> = ({ text, size = 34 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
const tiles = words.length >= 4 ? words.slice(0, 4) : ["AGENT", text.slice(0, 12).toUpperCase(), "TOOLS", "MEMORY"];
return (
<div style={{ position: "absolute", left: "14%", right: "14%", top: "30%", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
{tiles.map((w, i) => {
const p = spring({ frame: Math.max(0, frame - i * 6), fps, config: { damping: 16, stiffness: 100 } });
const r = i % 2 === 0 ? 14 : 20;
return (
<div
key={i}
style={{
border: "2px solid rgba(255,255,255,0.9)", borderRadius: 18,
minHeight: 140, display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size,
color: "#fff", textAlign: "center", padding: "0 14px",
transform: `scale(${0.4 + p * 0.6}) rotate(${(r - 14) * (1 - p)}deg)`,
opacity: p,
}}
>
{w}
</div>
);
})}
</div>
);
};
/** Scoreboard — Q&A panel (question highlighted, answer slides in). */
export const ScoreboardScene: React.FC<{ text: string; size?: number }> = ({ text, size = 38 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame, fps, config: { damping: 16, stiffness: 75 } });
const words = text.split(" ");
const q = words.slice(0, Math.ceil(words.length / 2)).join(" ");
const a = words.slice(Math.ceil(words.length / 2)).join(" ");
return (
<div style={{ position: "absolute", left: "10%", right: "10%", top: "26%" }}>
<div
style={{
background: "#fff", color: "#000", borderRadius: 18, padding: "26px 34px",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size, lineHeight: 1.2,
transform: `translateY(${(1 - p) * 60}px)`, opacity: p,
}}
>
<span style={{ opacity: 0.5, fontSize: size - 14, marginRight: 14 }}>Q</span>
{q}
</div>
<div
style={{
marginTop: 22, border: "2px solid #fff", borderRadius: 18, padding: "26px 34px",
fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: size - 6, lineHeight: 1.2,
color: "#fff", transform: `translateY(${(1 - p) * 60}px)`, opacity: Math.max(0, p * 2 - 1),
}}
>
<span style={{ opacity: 0.6, fontSize: size - 14, marginRight: 14 }}>A</span>
{a}
</div>
</div>
);
}; };