feat(shorts): render source content (tweets/threads/news) as visual cards
- New scripts/source-fetch.mjs: fetch tweet/thread via fxtwitter API +
Jina fallback; news via r.jina.ai (title/author/excerpt/paragraphs)
- New src/remotion/cards.tsx: TweetCard, ThreadCard, NewsCard — mono,
flat, white cards w/ avatar initial, handle, text, stats (views/likes/
replies/retweets) or headline+excerpt; ThreadCard stacks tweets
- ShortsVideo: new scene types tweet/thread/news; source card scene
REPLACES the story segment's visual (duration kept identical so the
audio/video mux stays in sync) — the voiceover keeps playing while the
actual tweet/news is shown on screen
- Worker: fetch sourceUrl when present → sourceToCard() → inject card
- Schema: YoutubeScript.sourceUrl + sourceData (Json)
- UI: approved scripts get a '🔗 Tweet/News URL' input; Render Shorts
saves sourceUrl then queues the render
- Verified: card scene renders (bright%≈20.8 vs caption 3.9 in pixel
histogram); TSC clean; frame extraction OK
This commit is contained in:
@@ -57,6 +57,9 @@ interface Script {
|
||||
seoChapters?: { time: string; label: string }[];
|
||||
titleVariants?: string[];
|
||||
thumbnailConcept?: string;
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// funnel stage tone: TOF = accent, MOF = warn, BOF = up
|
||||
@@ -449,12 +452,29 @@ export default function YouTubePage() {
|
||||
)}
|
||||
|
||||
{script.status === "approved" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); queueRender(script.id); }}
|
||||
disabled={rendersBusy.has(script.id)}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors disabled:opacity-60"
|
||||
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
||||
>{rendersBusy.has(script.id) ? "⏳ Queuing..." : "✨ Render Shorts"}</button>
|
||||
<>
|
||||
<input
|
||||
placeholder="🔗 Tweet / News URL (opsional)"
|
||||
defaultValue={script.sourceUrl || ""}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => { e.currentTarget.dataset.url = e.target.value; }}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] w-48 outline-none"
|
||||
style={{ background: "color-mix(in srgb, #fff 8%, transparent)", border: "1px solid color-mix(in srgb, #fff 18%, transparent)", color: "inherit" }}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = (e.currentTarget.parentElement?.querySelector("input[data-url]") as HTMLInputElement)?.value || "";
|
||||
if (v.trim()) {
|
||||
updateScript(script.id, { status: "approved", sourceUrl: v.trim() });
|
||||
}
|
||||
queueRender(script.id);
|
||||
}}
|
||||
disabled={rendersBusy.has(script.id)}
|
||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors disabled:opacity-60"
|
||||
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
||||
>{rendersBusy.has(script.id) ? "⏳ Queuing..." : "✨ Render Shorts"}</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{script.status === "render_queued" && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatCounter,
|
||||
SlideMotion,
|
||||
} from "./parts";
|
||||
import { TweetCard, ThreadCard, NewsCard } from "./cards";
|
||||
|
||||
/** Sentence timing (karaoke), relative to segment start. */
|
||||
export type Sentence = { w: string; start: number; end: number };
|
||||
@@ -20,8 +21,25 @@ export type Segment = {
|
||||
label?: string;
|
||||
sentences?: Sentence[];
|
||||
/** optional explicit scene override */
|
||||
scene?: "title" | "stat" | "caption" | "cta";
|
||||
scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news";
|
||||
stat?: { value: string; label: string };
|
||||
/** source content (tweet/thread/news) — rendered as a visual card scene */
|
||||
source?: {
|
||||
type: "tweet" | "thread" | "news";
|
||||
handle?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
isRoot?: boolean;
|
||||
likes?: number | null;
|
||||
retweets?: number | null;
|
||||
replies?: number | null;
|
||||
views?: number | null;
|
||||
tweets?: { text: string; name?: string }[];
|
||||
title?: string;
|
||||
excerpt?: string;
|
||||
author?: string;
|
||||
source?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Truncate at a word boundary (never cut mid-word). */
|
||||
@@ -51,11 +69,12 @@ function extractStat(text: string): { value: string; label: string } | null {
|
||||
}
|
||||
|
||||
/** Pick a scene for this segment (variety — pattern interrupt every cut). */
|
||||
function pickScene(seg: Segment, index: number): "title" | "stat" | "caption" | "cta" {
|
||||
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";
|
||||
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";
|
||||
@@ -76,6 +95,7 @@ const Scene: React.FC<{
|
||||
seg.sentences && seg.sentences.length
|
||||
? seg.sentences
|
||||
: [{ w: text, start: 0, end: duration / 30 }];
|
||||
const src = seg.source;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
@@ -83,7 +103,26 @@ const Scene: React.FC<{
|
||||
<SlideMotion>
|
||||
{label ? <BrandTag label={label} /> : null}
|
||||
|
||||
{scene === "stat" && stat ? (
|
||||
{scene === "tweet" && src ? (
|
||||
<TweetCard
|
||||
handle={src.handle || ""}
|
||||
name={src.name || src.handle || ""}
|
||||
text={src.text || text}
|
||||
likes={src.likes}
|
||||
retweets={src.retweets}
|
||||
replies={src.replies}
|
||||
views={src.views}
|
||||
/>
|
||||
) : scene === "thread" && src?.tweets ? (
|
||||
<ThreadCard handle={src.handle || ""} tweets={src.tweets} />
|
||||
) : scene === "news" && src ? (
|
||||
<NewsCard
|
||||
title={src.title || text}
|
||||
excerpt={src.excerpt}
|
||||
author={src.author}
|
||||
source={src.source}
|
||||
/>
|
||||
) : scene === "stat" && stat ? (
|
||||
<StatCounter value={stat.value} label={stat.label} />
|
||||
) : scene === "title" || scene === "cta" ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame, spring, useVideoConfig } from "remotion";
|
||||
import { FONT_BODY, FONT_DISPLAY } from "./parts";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Source-content cards — mono, flat, editorial.
|
||||
Rendered as scenes between/around TTS caption scenes so the video shows
|
||||
the actual tweet/thread/news, not just subtitles.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const WHITE = "#ffffff";
|
||||
const BLACK = "#000000";
|
||||
const GREY = "#8a8a90";
|
||||
|
||||
function slug(s: string, n = 12): string {
|
||||
return (s || "").length > n ? s.slice(0, n - 1) + "…" : s;
|
||||
}
|
||||
|
||||
function splitLines(text: string, perLine = 34): string[] {
|
||||
const words = (text || "").split(/\s+/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
for (const w of words) {
|
||||
if ((cur + " " + w).trim().length > perLine) {
|
||||
if (cur) lines.push(cur.trim());
|
||||
cur = w;
|
||||
} else {
|
||||
cur = (cur + " " + w).trim();
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur.trim());
|
||||
return lines.slice(0, 7); // cap
|
||||
}
|
||||
|
||||
/** Staggered fade-up for card text blocks. */
|
||||
function useStagger(total: number, step = 6) {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
|
||||
return (i: number) => {
|
||||
const o = spring({ frame: frame - i * step, fps, config: { damping: 16, stiffness: 90 }, delay: 0 });
|
||||
return {
|
||||
opacity: Math.max(0, Math.min(1, frame - i * step) / 8),
|
||||
transform: `translateY(${(1 - o) * 22}px)`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** ── Tweet / Thread card ─────────────────────────────────────────────── */
|
||||
export const TweetCard: React.FC<{
|
||||
handle: string;
|
||||
name: string;
|
||||
text: string;
|
||||
likes?: number | null;
|
||||
retweets?: number | null;
|
||||
replies?: number | null;
|
||||
views?: number | null;
|
||||
}> = ({ handle, name, text, likes, retweets, replies, views }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
|
||||
const count = (v?: number | null) => (v == null ? null : formatCount(v));
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "0 7%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
background: WHITE,
|
||||
borderRadius: 28,
|
||||
padding: "42px 44px",
|
||||
transform: `translateY(${(1 - pop) * 30}px) scale(${0.94 + pop * 0.06})`,
|
||||
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* header: avatar initial + name/handle */}
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 30 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 40,
|
||||
background: BLACK,
|
||||
color: WHITE,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 36,
|
||||
fontWeight: 800,
|
||||
fontFamily: FONT_DISPLAY,
|
||||
marginRight: 22,
|
||||
}}
|
||||
>
|
||||
{(name || handle || "?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: FONT_DISPLAY, fontWeight: 800, fontSize: 40, color: BLACK, lineHeight: 1.1 }}>
|
||||
{slug(name, 22)}
|
||||
</div>
|
||||
<div style={{ fontFamily: FONT_BODY, fontWeight: 600, fontSize: 30, color: GREY }}>
|
||||
@{handle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tweet body */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontWeight: 600,
|
||||
fontSize: 46,
|
||||
lineHeight: 1.35,
|
||||
color: BLACK,
|
||||
letterSpacing: "-0.01em",
|
||||
marginBottom: 34,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
{/* stats row */}
|
||||
<div style={{ display: "flex", gap: 40, fontFamily: FONT_BODY, fontWeight: 700, fontSize: 30, color: GREY }}>
|
||||
{count(views) !== null && <span>▶ {count(views)}</span>}
|
||||
{count(replies) !== null && <span>↩ {count(replies)}</span>}
|
||||
{count(retweets) !== null && <span>↻ {count(retweets)}</span>}
|
||||
{count(likes) !== null && <span>♥ {count(likes)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** ── Thread card (multiple tweets stacked) ───────────────────────────── */
|
||||
export const ThreadCard: React.FC<{
|
||||
handle: string;
|
||||
tweets: { text: string; name?: string }[];
|
||||
}> = ({ handle, tweets }) => {
|
||||
const anim = useStagger(tweets.length);
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", padding: "0 6%" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_DISPLAY,
|
||||
fontWeight: 800,
|
||||
fontSize: 38,
|
||||
color: WHITE,
|
||||
marginBottom: 24,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
🧵 @{handle} · thread
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{tweets.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: i % 2 === 0 ? WHITE : "#e4e4e8",
|
||||
borderRadius: 20,
|
||||
padding: "24px 28px",
|
||||
fontFamily: FONT_BODY,
|
||||
fontWeight: 600,
|
||||
fontSize: 36,
|
||||
lineHeight: 1.3,
|
||||
color: BLACK,
|
||||
opacity: anim(i).opacity,
|
||||
transform: anim(i).transform,
|
||||
}}
|
||||
>
|
||||
{splitLines(t.text, 30).join("\n")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** ── News / article card ─────────────────────────────────────────────── */
|
||||
export const NewsCard: React.FC<{
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
author?: string;
|
||||
source?: string;
|
||||
}> = ({ title, excerpt, author, source }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 14, stiffness: 100 } });
|
||||
const lines = splitLines(excerpt || title, 30);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", padding: "0 7%" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
background: WHITE,
|
||||
borderRadius: 28,
|
||||
padding: "46px 44px",
|
||||
transform: `translateY(${(1 - pop) * 30}px)`,
|
||||
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* news kicker */}
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background: BLACK,
|
||||
color: WHITE,
|
||||
fontFamily: FONT_BODY,
|
||||
fontWeight: 800,
|
||||
fontSize: 26,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
padding: "8px 18px",
|
||||
borderRadius: 8,
|
||||
marginBottom: 26,
|
||||
}}
|
||||
>
|
||||
{source || "News"}
|
||||
</div>
|
||||
|
||||
{/* headline */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_DISPLAY,
|
||||
fontWeight: 800,
|
||||
fontSize: 54,
|
||||
lineHeight: 1.15,
|
||||
color: BLACK,
|
||||
letterSpacing: "-0.02em",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{splitLines(title, 26).join("\n")}
|
||||
</div>
|
||||
|
||||
{/* excerpt */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontWeight: 500,
|
||||
fontSize: 36,
|
||||
lineHeight: 1.4,
|
||||
color: "#3a3a40",
|
||||
borderTop: "2px solid #000",
|
||||
paddingTop: 22,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{lines.join("\n")}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
{(author || source) && (
|
||||
<div style={{ fontFamily: FONT_BODY, fontWeight: 600, fontSize: 26, color: GREY, marginTop: 20 }}>
|
||||
{author ? `— ${author}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Helper: format numbers compactly (1234 → 1.2K). */
|
||||
export function formatCount(n: number): string {
|
||||
if (n == null) return "0";
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
Reference in New Issue
Block a user