diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d640619..674e733 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -169,6 +169,9 @@ model YoutubeScript { videoUrl String? thumbnailUrl String? render YoutubeRender? + // Source content (tweet/thread/news) rendered as visual cards + sourceUrl String? + sourceData Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/scripts/render-worker.mjs b/scripts/render-worker.mjs index f4a3f31..9b81bbc 100644 --- a/scripts/render-worker.mjs +++ b/scripts/render-worker.mjs @@ -19,6 +19,7 @@ import { PrismaClient } from "@prisma/client"; import { spawnSync } from "child_process"; import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs"; import path from "path"; +import { execFileSync } from "child_process"; const prisma = new PrismaClient(); const RENDER_DIR = process.env.RENDER_DIR || "/data/renders"; @@ -45,6 +46,58 @@ const TTS_PYTHON = ? "/home/code/.hermes/hermes-agent/venv/bin/python3" : "python3"); 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 = {}) { 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)}"`); } + // 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) let totalSec = voiced.reduce((a, s) => a + s.durationSec, 0); if (totalSec > MAX_TOTAL_SEC) { @@ -171,6 +233,22 @@ async function renderScript(script) { 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) const silentMp4 = path.join(work, "silent.mp4"); log(`[${id}] Remotion render ${timeline.length} segs → silent.mp4`); diff --git a/scripts/source-fetch.mjs b/scripts/source-fetch.mjs new file mode 100644 index 0000000..fdd4849 --- /dev/null +++ b/scripts/source-fetch.mjs @@ -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 [--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 "); + const out = isTweetUrl(url) ? await fetchTwitter(url) : await fetchNews(url); + console.log(JSON.stringify(out)); +} + +main().catch((e) => err(e.message)); \ No newline at end of file diff --git a/src/app/youtube/page.tsx b/src/app/youtube/page.tsx index 23d6995..cd42f09 100644 --- a/src/app/youtube/page.tsx +++ b/src/app/youtube/page.tsx @@ -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" && ( - + <> + 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" }} + /> + + )} {script.status === "render_queued" && ( diff --git a/src/remotion/ShortsVideo.tsx b/src/remotion/ShortsVideo.tsx index 6d782aa..38da25c 100644 --- a/src/remotion/ShortsVideo.tsx +++ b/src/remotion/ShortsVideo.tsx @@ -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 ( @@ -83,7 +103,26 @@ const Scene: React.FC<{ {label ? : null} - {scene === "stat" && stat ? ( + {scene === "tweet" && src ? ( + + ) : scene === "thread" && src?.tweets ? ( + + ) : scene === "news" && src ? ( + + ) : scene === "stat" && stat ? ( ) : scene === "title" || scene === "cta" ? ( <> diff --git a/src/remotion/cards.tsx b/src/remotion/cards.tsx new file mode 100644 index 0000000..8c3eb13 --- /dev/null +++ b/src/remotion/cards.tsx @@ -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 ( + +
+ {/* header: avatar initial + name/handle */} +
+
+ {(name || handle || "?")[0].toUpperCase()} +
+
+
+ {slug(name, 22)} +
+
+ @{handle} +
+
+
+ + {/* tweet body */} +
+ {text} +
+ + {/* stats row */} +
+ {count(views) !== null && ▶ {count(views)}} + {count(replies) !== null && ↩ {count(replies)}} + {count(retweets) !== null && ↻ {count(retweets)}} + {count(likes) !== null && ♥ {count(likes)}} +
+
+
+ ); +}; + +/** ── 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 ( + +
+ 🧵 @{handle} · thread +
+
+ {tweets.map((t, i) => ( +
+ {splitLines(t.text, 30).join("\n")} +
+ ))} +
+
+ ); +}; + +/** ── 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 ( + +
+ {/* news kicker */} +
+ {source || "News"} +
+ + {/* headline */} +
+ {splitLines(title, 26).join("\n")} +
+ + {/* excerpt */} +
+ {lines.join("\n")} +
+ + {/* footer */} + {(author || source) && ( +
+ {author ? `— ${author}` : ""} +
+ )} +
+
+ ); +}; + +/** 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); +} \ No newline at end of file