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:
1 parent
4a61766ee4
commit
9c6ea226f8
6 files changed
+578
-9
No files matched your search
@@ -169,6 +169,9 @@ model YoutubeScript {
|
|||||||
videoUrl String?
|
videoUrl String?
|
||||||
thumbnailUrl String?
|
thumbnailUrl String?
|
||||||
render YoutubeRender?
|
render YoutubeRender?
|
||||||
|
// Source content (tweet/thread/news) rendered as visual cards
|
||||||
|
sourceUrl String?
|
||||||
|
sourceData Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { execFileSync } from "child_process";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const RENDER_DIR = process.env.RENDER_DIR || "/data/renders";
|
const RENDER_DIR = process.env.RENDER_DIR || "/data/renders";
|
||||||
@@ -45,6 +46,58 @@ const TTS_PYTHON =
|
|||||||
? "/home/code/.hermes/hermes-agent/venv/bin/python3"
|
? "/home/code/.hermes/hermes-agent/venv/bin/python3"
|
||||||
: "python3");
|
: "python3");
|
||||||
const TTS_WORDS_PY = path.resolve(process.env.TTS_WORDS_PY || "./scripts/tts-words.py");
|
const TTS_WORDS_PY = path.resolve(process.env.TTS_WORDS_PY || "./scripts/tts-words.py");
|
||||||
|
const SOURCE_FETCH_MJS = path.resolve(process.env.SOURCE_FETCH_MJS || "./scripts/source-fetch.mjs");
|
||||||
|
|
||||||
|
/** Fetch source content (tweet/thread/news) for a URL via source-fetch.mjs. */
|
||||||
|
function fetchSource(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const out = execFileSync(process.execPath, [SOURCE_FETCH_MJS, url], {
|
||||||
|
timeout: 40_000,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return JSON.parse(out);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert fetched source data → src card shape for Remotion scenes. */
|
||||||
|
function sourceToCard(fetched) {
|
||||||
|
if (!fetched || fetched.type === "error") return null;
|
||||||
|
if (fetched.type === "tweet") {
|
||||||
|
const root = fetched.tweets?.find((t) => t.isRoot) || fetched.tweets?.[0];
|
||||||
|
if (!root) return null;
|
||||||
|
if (fetched.tweets.length > 1) {
|
||||||
|
return {
|
||||||
|
type: "thread",
|
||||||
|
handle: fetched.handle,
|
||||||
|
tweets: fetched.tweets.map((t) => ({ text: t.text, name: t.name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "tweet",
|
||||||
|
handle: root.handle,
|
||||||
|
name: root.name,
|
||||||
|
text: root.text,
|
||||||
|
likes: root.likes,
|
||||||
|
retweets: root.retweets,
|
||||||
|
replies: root.replies,
|
||||||
|
views: root.views,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (fetched.type === "news") {
|
||||||
|
return {
|
||||||
|
type: "news",
|
||||||
|
title: fetched.title,
|
||||||
|
excerpt: fetched.excerpt,
|
||||||
|
author: fetched.author,
|
||||||
|
source: new URL(fetched.url).hostname.replace(/^www\./, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function sh(cmd, args, opts = {}) {
|
function sh(cmd, args, opts = {}) {
|
||||||
const r = spawnSync(cmd, args, {
|
const r = spawnSync(cmd, args, {
|
||||||
@@ -146,6 +199,15 @@ async function renderScript(script) {
|
|||||||
log(`[${id}] seg${i}: ${t.durationSec.toFixed(2)}s "${s.text.slice(0, 40)}"`);
|
log(`[${id}] seg${i}: ${t.durationSec.toFixed(2)}s "${s.text.slice(0, 40)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. Fetch source content (tweet/thread/news) if script has sourceUrl
|
||||||
|
let sourceCard = null;
|
||||||
|
if (script.sourceUrl) {
|
||||||
|
log(`[${id}] fetching source: ${script.sourceUrl}`);
|
||||||
|
const fetched = fetchSource(script.sourceUrl);
|
||||||
|
sourceCard = sourceToCard(fetched);
|
||||||
|
log(`[${id}] source → ${sourceCard ? sourceCard.type : "fetch failed (skip card)"}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Trim to ≤ MAX_TOTAL_SEC (keep head, drop tail)
|
// 2. Trim to ≤ MAX_TOTAL_SEC (keep head, drop tail)
|
||||||
let totalSec = voiced.reduce((a, s) => a + s.durationSec, 0);
|
let totalSec = voiced.reduce((a, s) => a + s.durationSec, 0);
|
||||||
if (totalSec > MAX_TOTAL_SEC) {
|
if (totalSec > MAX_TOTAL_SEC) {
|
||||||
@@ -171,6 +233,22 @@ async function renderScript(script) {
|
|||||||
stat: extractStatData(s.text),
|
stat: extractStatData(s.text),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Inject source card scene — REPLACES the visual of the 2nd segment
|
||||||
|
// (story). Total timeline duration stays == audio duration, so the mux
|
||||||
|
// stays in sync (no -shortest tail loss). The story voiceover keeps
|
||||||
|
// playing while the actual tweet/news card is shown on screen.
|
||||||
|
if (sourceCard && timeline.length >= 2) {
|
||||||
|
const idx = Math.min(1, timeline.length - 1);
|
||||||
|
const cardScene = {
|
||||||
|
text: sourceCard.type === "news" ? (sourceCard.title || "") : (sourceCard.text || sourceCard.tweets?.[0]?.text || ""),
|
||||||
|
duration: timeline[idx].duration, // same duration — replaces, not adds
|
||||||
|
label: sourceCard.type === "news" ? "SOURCE" : "ORIGINAL",
|
||||||
|
source: sourceCard,
|
||||||
|
};
|
||||||
|
timeline.splice(idx, 1, cardScene);
|
||||||
|
log(`[${id}] replaced seg${idx} visual with ${sourceCard.type} card (${cardScene.duration / FPS}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Render silent video via Remotion CLI (props = segments JSON)
|
// 4. Render silent video via Remotion CLI (props = segments JSON)
|
||||||
const silentMp4 = path.join(work, "silent.mp4");
|
const silentMp4 = path.join(work, "silent.mp4");
|
||||||
log(`[${id}] Remotion render ${timeline.length} segs → silent.mp4`);
|
log(`[${id}] Remotion render ${timeline.length} segs → silent.mp4`);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fetch source content for shorts rendering.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - Twitter/X: thread via fxtwitter API (status pages → JSON)
|
||||||
|
* url https://x.com/user/status/1234 (or twitter.com)
|
||||||
|
* returns { tweets: [{ id, handle, name, text, media?, likes, retweets, replies, views }] }
|
||||||
|
* - News/article: via Jina Reader (r.jina.ai) — text extraction
|
||||||
|
* url any http(s) article
|
||||||
|
* returns { title, url, author, published, text }
|
||||||
|
*
|
||||||
|
* Usage: node source-fetch.mjs <url> [--thread] [--maxTweets N]
|
||||||
|
* Output: JSON to stdout: { type: "tweet"|"news"|"error", ... }
|
||||||
|
*/
|
||||||
|
const MAX_TWEETS = Number(process.argv.find((a, i) => process.argv[i - 1] === "--maxTweets") || 6);
|
||||||
|
|
||||||
|
function err(msg, extra = {}) {
|
||||||
|
console.log(JSON.stringify({ type: "error", error: msg, ...extra }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTweetUrl(u) {
|
||||||
|
return /(?:twitter\.com|x\.com)\/\w+\/status\/\d+/i.test(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTweetId(u) {
|
||||||
|
const m = u.match(/status\/(\d+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHandle(u) {
|
||||||
|
const m = u.match(/(?:twitter\.com|x\.com)\/([A-Za-z0-9_]+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTwitter(u) {
|
||||||
|
const id = parseTweetId(u);
|
||||||
|
const handle = parseHandle(u);
|
||||||
|
if (!id || !handle) err("invalid tweet url");
|
||||||
|
|
||||||
|
// Try fxtwitter API for the tweet + thread context
|
||||||
|
const api = `https://api.fxtwitter.com/${handle}/status/${id}`;
|
||||||
|
const res = await fetch(api, { headers: { "User-Agent": "hamc-shorts/1.0" } });
|
||||||
|
if (!res.ok) err(`fxtwitter ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const t = data?.tweet;
|
||||||
|
if (!t) err("no tweet data", { raw: JSON.stringify(data).slice(0, 200) });
|
||||||
|
|
||||||
|
// Collect thread: current tweet + replies chain (tweet.replies / quoted)
|
||||||
|
const tweets = [];
|
||||||
|
const pushTweet = (tw, isRoot = false) => {
|
||||||
|
if (!tw) return;
|
||||||
|
const text = (tw.text || tw.rawText || "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
tweets.push({
|
||||||
|
id: tw.id || tw.id_str || null,
|
||||||
|
handle: tw.author?.screen_name || handle,
|
||||||
|
name: tw.author?.name || tw.author?.user_name || handle,
|
||||||
|
text: text.slice(0, 500), // safety
|
||||||
|
media: tw.media?.all?.length ? tw.media.all.map((m) => ({ type: m.type, url: m.url, thumb: m.thumbnail_url })) : [],
|
||||||
|
likes: tw.likes ?? null,
|
||||||
|
retweets: tw.retweets ?? null,
|
||||||
|
replies: tw.replies ?? null,
|
||||||
|
views: tw.views ?? null,
|
||||||
|
isRoot,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pushTweet(t, true);
|
||||||
|
|
||||||
|
// Try to pull thread replies if fxtwitter exposed them (data.tweet.replies can be a list)
|
||||||
|
const replies = Array.isArray(t.replies) ? t.replies : Array.isArray(data?.tweet?.thread) ? data.tweet.thread : [];
|
||||||
|
for (const r of replies.slice(0, MAX_TWEETS - 1)) pushTweet(r);
|
||||||
|
|
||||||
|
if (tweets.length === 1) {
|
||||||
|
// No thread from API; attempt simple scrape via Jina on the status URL (best effort)
|
||||||
|
try {
|
||||||
|
const j = await fetch(`https://r.jina.ai/${u}`, {
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0", "X-Return-Format": "text" },
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
if (j.ok) {
|
||||||
|
const text = (await j.text()).slice(0, 4000);
|
||||||
|
// split into thread-ish sentences by newlines
|
||||||
|
const lines = text.split("\n").map((s) => s.trim()).filter((s) => s.length > 20 && !s.startsWith("http") && !/^\d+\s*(like|retweet|reply|view)s?$/i.test(s));
|
||||||
|
if (lines.length > 1) {
|
||||||
|
tweets.push(...lines.slice(0, MAX_TWEETS - 1).map((txt, i) => ({
|
||||||
|
id: null, handle, name: tweets[0].name, text: txt.slice(0, 500), isRoot: false,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep single tweet */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "tweet", id, handle, tweets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNews(u) {
|
||||||
|
const res = await fetch(`https://r.jina.ai/${u}`, {
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0 (shorts-renderer/1.0)", "X-Return-Format": "markdown" },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!res.ok) err(`jina ${res.status}`);
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
|
||||||
|
// Jina frontmatter: Title, URL, date, author, then body
|
||||||
|
let title = "";
|
||||||
|
let author = "";
|
||||||
|
let published = "";
|
||||||
|
let body = text;
|
||||||
|
const fm = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||||
|
if (fm) {
|
||||||
|
body = fm[2].trim();
|
||||||
|
const titleM = fm[1].match(/^Title:\s*(.+)$/m);
|
||||||
|
const authorM = fm[1].match(/^Author:\s*(.+)$/m);
|
||||||
|
const dateM = fm[1].match(/^(?:date|Date|Published):\s*(.+)$/m);
|
||||||
|
if (titleM) title = titleM[1].trim();
|
||||||
|
if (authorM) author = authorM[1].trim();
|
||||||
|
if (dateM) published = dateM[1].trim();
|
||||||
|
}
|
||||||
|
if (!title) {
|
||||||
|
const h = body.match(/^#\s+(.+)$/m);
|
||||||
|
if (h) title = h[1].trim();
|
||||||
|
}
|
||||||
|
if (!title) title = u;
|
||||||
|
|
||||||
|
// Keep the first N paragraphs as the "news excerpt" (headlines + key facts)
|
||||||
|
const paras = body
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((s) => s.replace(/^#{1,3}\s+/, "").trim())
|
||||||
|
.filter((s) => s.length > 40 && s.length < 500 && !/^https?:\/\//i.test(s));
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "news",
|
||||||
|
url: u,
|
||||||
|
title: title.slice(0, 200),
|
||||||
|
author: author.slice(0, 100),
|
||||||
|
published: published.slice(0, 60),
|
||||||
|
excerpt: paras.slice(0, 4).join("\n\n"),
|
||||||
|
paragraphs: paras.slice(0, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.argv[2];
|
||||||
|
if (!url) err("usage: source-fetch.mjs <url>");
|
||||||
|
const out = isTweetUrl(url) ? await fetchTwitter(url) : await fetchNews(url);
|
||||||
|
console.log(JSON.stringify(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => err(e.message));
|
||||||
@@ -57,6 +57,9 @@ interface Script {
|
|||||||
seoChapters?: { time: string; label: string }[];
|
seoChapters?: { time: string; label: string }[];
|
||||||
titleVariants?: string[];
|
titleVariants?: string[];
|
||||||
thumbnailConcept?: string;
|
thumbnailConcept?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// funnel stage tone: TOF = accent, MOF = warn, BOF = up
|
// funnel stage tone: TOF = accent, MOF = warn, BOF = up
|
||||||
@@ -449,12 +452,29 @@ export default function YouTubePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{script.status === "approved" && (
|
{script.status === "approved" && (
|
||||||
<button
|
<>
|
||||||
onClick={(e) => { e.stopPropagation(); queueRender(script.id); }}
|
<input
|
||||||
disabled={rendersBusy.has(script.id)}
|
placeholder="🔗 Tweet / News URL (opsional)"
|
||||||
className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors disabled:opacity-60"
|
defaultValue={script.sourceUrl || ""}
|
||||||
style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" }}
|
onClick={e => e.stopPropagation()}
|
||||||
>{rendersBusy.has(script.id) ? "⏳ Queuing..." : "✨ Render Shorts"}</button>
|
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" && (
|
{script.status === "render_queued" && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
StatCounter,
|
StatCounter,
|
||||||
SlideMotion,
|
SlideMotion,
|
||||||
} from "./parts";
|
} from "./parts";
|
||||||
|
import { TweetCard, ThreadCard, NewsCard } from "./cards";
|
||||||
|
|
||||||
/** Sentence timing (karaoke), relative to segment start. */
|
/** Sentence timing (karaoke), relative to segment start. */
|
||||||
export type Sentence = { w: string; start: number; end: number };
|
export type Sentence = { w: string; start: number; end: number };
|
||||||
@@ -20,8 +21,25 @@ export type Segment = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
sentences?: Sentence[];
|
sentences?: Sentence[];
|
||||||
/** optional explicit scene override */
|
/** optional explicit scene override */
|
||||||
scene?: "title" | "stat" | "caption" | "cta";
|
scene?: "title" | "stat" | "caption" | "cta" | "tweet" | "thread" | "news";
|
||||||
stat?: { value: string; label: string };
|
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). */
|
/** 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). */
|
/** 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;
|
if (seg.scene) return seg.scene;
|
||||||
const label = (seg.label || "").toUpperCase();
|
const label = (seg.label || "").toUpperCase();
|
||||||
if (label === "HOOK" || label === "ON SCREEN") return "title";
|
if (label === "HOOK" || label === "ON SCREEN") return "title";
|
||||||
if (label === "CTA") return "cta";
|
if (label === "CTA") return "cta";
|
||||||
|
if (seg.source) return seg.source.type as "tweet" | "thread" | "news";
|
||||||
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";
|
||||||
@@ -76,6 +95,7 @@ const Scene: React.FC<{
|
|||||||
seg.sentences && seg.sentences.length
|
seg.sentences && seg.sentences.length
|
||||||
? seg.sentences
|
? seg.sentences
|
||||||
: [{ w: text, start: 0, end: duration / 30 }];
|
: [{ w: text, start: 0, end: duration / 30 }];
|
||||||
|
const src = seg.source;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill>
|
<AbsoluteFill>
|
||||||
@@ -83,7 +103,26 @@ const Scene: React.FC<{
|
|||||||
<SlideMotion>
|
<SlideMotion>
|
||||||
{label ? <BrandTag label={label} /> : null}
|
{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} />
|
<StatCounter value={stat.value} label={stat.label} />
|
||||||
) : scene === "title" || scene === "cta" ? (
|
) : 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