3
0

2 Commity 8b7566a5c6 ... 9db594f446

Autor SHA1 Správa Dátum
  lkatzey 9db594f446 新增github-trending视频模板 1 mesiac pred
  lkatzey 4a4d7f2dd6 数据源接口更新 1 mesiac pred

+ 1 - 0
apps/web/src/app/create/page.tsx

@@ -8,6 +8,7 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
   knowledge: { label: "知识讲解", desc: "Step-by-step explanation with key point cards", color: "#0d9488" },
   opinion: { label: "观点分享", desc: "Quote-style layout for editorial content", color: "#ea580c" },
   marketing: { label: "产品营销", desc: "Product showcase with CTA overlays", color: "#9333ea" },
+  "github-trending": { label: "GitHub 热榜", desc: "Repo cards with social preview + star history", color: "#22c55e" },
 };
 
 const PLATFORMS_INFO: Record<string, string> = {

+ 7 - 1
apps/web/src/app/templates/page.tsx

@@ -25,6 +25,12 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
     color: "#9333ea",
     layouts: "Feature grid, CTA buttons, product showcase",
   },
+  "github-trending": {
+    label: "GitHub 热榜",
+    desc: "Per-repo card with social preview image, star history chart, language/stars/license tags and rotating dark backgrounds. Best for daily trending roundups.",
+    color: "#22c55e",
+    layouts: "Repo header + tags, two-column image/text body",
+  },
 };
 
 export default function TemplatesPage() {
@@ -32,7 +38,7 @@ export default function TemplatesPage() {
     <div>
       <div className="page-header">
         <h1>Templates</h1>
-        <p>Choose from 4 professionally designed video templates, each with 16:9 and 9:16 variants</p>
+        <p>Choose from 5 professionally designed video templates, each with 16:9 and 9:16 variants</p>
       </div>
 
       <div className="card-grid">

+ 3 - 0
config/default.yaml

@@ -62,6 +62,9 @@ templates:
   marketing:
     primaryColor: "#9333ea"
     accentColor: "#ec4899"
+  github-trending:
+    primaryColor: "#22c55e"
+    accentColor: "#3b82f6"
 
 collect:
   github-trending:

+ 33 - 12
packages/collect/src/collectors/github-daily.ts

@@ -47,7 +47,7 @@ class GitHubDailyCollector implements DataSource {
         if (!res.ok) continue;
         const raw = await res.json() as any;
         const data = raw.data ?? raw;
-        details.push(formatRepo(data));
+        details.push(formatRepo(owner, name, data));
       } catch {
         // Skip failed repo lookups
       }
@@ -72,21 +72,42 @@ function formatTrendingSummary(repos: any[]): string {
   return lines.join("\n");
 }
 
-function formatRepo(data: any): string {
-  const name = data.fullName || data.full_name || "unknown";
-  const lines: string[] = [`## ${name}`];
+function formatRepo(trendingOwner: string, trendingName: string, data: any): string {
+  // The repo detail API returns the REAL owner (e.g. facebook/react was
+  // transferred to the react org and now reports fullName "react/react").
+  // Prefer the detail's fullName when available, otherwise fall back to the
+  // trending-side owner/name used to query it.
+  const fullName = data.fullName || `${trendingOwner}/${trendingName}`;
+  const [detailOwner, detailName] = fullName.split("/");
+
+  const lines: string[] = [`## ${fullName}`];
 
   if (data.description) lines.push(`\n${data.description}`);
-  const meta: string[] = [];
-  if (data.language) meta.push(`Language: ${data.language}`);
-  if (data.stars != null) meta.push(`Stars: ${formatCount(data.stars)}`);
-  else if (data.stargazers_count != null) meta.push(`Stars: ${formatCount(data.stargazers_count)}`);
-  if (data.forks != null) meta.push(`Forks: ${formatCount(data.forks)}`);
-  if (data.topics?.length) meta.push(`Topics: ${data.topics.join(", ")}`);
-  if (meta.length) lines.push(meta.map((m) => `- ${m}`).join("\n"));
+
+  // Structured metadata block — the LLM is instructed to copy this verbatim
+  // into scene.github.repo. Do NOT edit field names or values here.
+  const meta = {
+    owner: detailOwner || trendingOwner,
+    name: detailName || trendingName,
+    fullName,
+    language: data.language ?? "",
+    languageColor: data.languageColor ?? "",
+    stars: data.stars,
+    forks: data.forks,
+    license: data.license ?? "",
+  };
+  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+
+  // Image refs — the LLM copies these into scene.images[] verbatim.
+  // socialPreview is resolved by the assets stage (calls crawler, decodes
+  // base64 PNG). starHistory is fetched as a normal URL image.
+  const imageRefs = {
+    socialPreview: `${detailOwner || trendingOwner}/${detailName || trendingName}`,
+    starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
+  };
+  lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
 
   if (data.readme) {
-    // Truncate readme to avoid excessive content
     const readme = data.readme.length > 2000 ? data.readme.slice(0, 2000) + "\n..." : data.readme;
     lines.push(`\n### README\n${readme}`);
   }

+ 49 - 15
packages/collect/src/collectors/github-repo.ts

@@ -33,24 +33,58 @@ class GitHubRepoCollector implements DataSource {
     const raw = (await response.json()) as Record<string, any>;
     const data = (raw.data ?? raw) as Record<string, any>;
 
-    const name = data.fullName || data.full_name || "unknown";
-    const lines: string[] = [`# ${name}\n`];
-    if (data.description) lines.push(`${data.description}\n`);
-    if (data.language) lines.push(`- Language: ${data.language}`);
-    if (data.stars != null) lines.push(`- Stars: ${formatCount(data.stars)}`);
-    if (data.forks != null) lines.push(`- Forks: ${formatCount(data.forks)}`);
-    if (data.openIssues != null) lines.push(`- Open Issues: ${formatCount(data.openIssues)}`);
-    if (data.topics?.length) lines.push(`- Topics: ${data.topics.join(", ")}`);
+    return { type: "text", content: formatRepoDetail(owner, repo, data) };
+  }
+}
+
+function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): string {
+  // The API returns the REAL owner (transferred repos report their current
+  // owner). Prefer data.fullName; fall back to the query params.
+  const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
+  const [detailOwner, detailName] = fullName.split("/");
+
+  const lines: string[] = [`# ${fullName}\n`];
+  if (data.description) lines.push(`${data.description}\n`);
+
+  // Structured metadata block — LLM copies verbatim into scene.github.repo.
+  const meta = {
+    owner: detailOwner || queryOwner,
+    name: detailName || queryRepo,
+    fullName,
+    language: data.language ?? "",
+    languageColor: data.languageColor ?? "",
+    stars: data.stars,
+    forks: data.forks,
+    license: data.license ?? "",
+  };
+  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+
+  // Image refs — LLM copies verbatim into scene.images[].
+  const imageRefs = {
+    socialPreview: `${detailOwner || queryOwner}/${detailName || queryRepo}`,
+    starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
+  };
+  lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
+
+  const meta2: string[] = [];
+  if (data.language) meta2.push(`Language: ${data.language}`);
+  if (data.stars != null) meta2.push(`Stars: ${formatCount(data.stars)}`);
+  if (data.forks != null) meta2.push(`Forks: ${formatCount(data.forks)}`);
+  if (data.openIssues != null) meta2.push(`Open Issues: ${formatCount(data.openIssues)}`);
+  if (data.topics?.length) meta2.push(`Topics: ${data.topics.join(", ")}`);
+  if (meta2.length) {
     lines.push("");
-    if (data.readme) {
-      const readme = data.readme.length > 3000
-        ? data.readme.slice(0, 3000) + "\n..."
-        : data.readme;
-      lines.push("## README\n", readme);
-    }
+    lines.push(meta2.map((m) => `- ${m}`).join("\n"));
+  }
 
-    return { type: "text", content: lines.join("\n") };
+  if (data.readme) {
+    const readme = data.readme.length > 3000
+      ? data.readme.slice(0, 3000) + "\n..."
+      : data.readme;
+    lines.push("\n## README\n", readme);
   }
+
+  return lines.join("\n");
 }
 
 registerCollector("github-repo", (config) => new GitHubRepoCollector(config));

+ 37 - 6
packages/collect/src/collectors/github-trending.ts

@@ -26,13 +26,44 @@ class GitHubTrendingCollector implements DataSource {
 
     const lines: string[] = ["# GitHub Trending\n"];
     for (const repo of items) {
-      const name = repo.fullName || `${repo.author}/${repo.name}`;
-      lines.push(`## ${name}`);
+      const owner = repo.author ?? "";
+      const name = repo.name ?? "";
+      const fullName = repo.fullName || `${owner}/${name}`;
+      if (!owner || !name) continue;
+
+      lines.push(`## ${fullName}`);
       if (repo.description) lines.push(`${repo.description}`);
-      if (repo.language) lines.push(`- Language: ${repo.language}`);
-      if (repo.stars != null) lines.push(`- Stars: ${formatCount(repo.stars)}`);
-      if (repo.currentPeriodStars != null) lines.push(`- Today: +${formatCount(repo.currentPeriodStars)}`);
-      if (repo.url) lines.push(`- URL: ${repo.url}`);
+
+      // Structured metadata block — LLM copies verbatim into scene.github.repo.
+      // The trending endpoint does not expose license; downstream template
+      // tolerates an empty license (no license tag rendered).
+      const meta = {
+        owner,
+        name,
+        fullName,
+        language: repo.language ?? "",
+        languageColor: repo.languageColor ?? "",
+        stars: repo.stars,
+        forks: repo.forks,
+        license: "",
+      };
+      lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+
+      // Image refs — LLM copies verbatim into scene.images[].
+      const imageRefs = {
+        socialPreview: `${owner}/${name}`,
+        starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
+      };
+      lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
+
+      const meta2: string[] = [];
+      if (repo.language) meta2.push(`Language: ${repo.language}`);
+      if (repo.stars != null) meta2.push(`Stars: ${formatCount(repo.stars)}`);
+      if (repo.currentPeriodStars != null) meta2.push(`Today: +${formatCount(repo.currentPeriodStars)}`);
+      if (repo.url) meta2.push(`URL: ${repo.url}`);
+      if (meta2.length) {
+        lines.push(meta2.map((m) => `- ${m}`).join("\n"));
+      }
       lines.push("");
     }
 

+ 148 - 25
packages/core/src/stages/assets.ts

@@ -3,6 +3,8 @@ import { join, resolve, extname, basename } from "node:path";
 import { mkdir, writeFile, copyFile } from "node:fs/promises";
 import { existsSync } from "node:fs";
 
+const REPO_DETAIL_URL = "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo";
+
 export interface AssetsConfig {
   template: TemplateType;
   aspect: AspectRatio;
@@ -29,13 +31,16 @@ export async function resolveAssets(
   // Deduplicate
   const seen = new Set<string>();
   const uniqueImages = imageEntries.filter((img) => {
-    const key = img.path || img.url || img.query || "";
+    const key = img.path || img.url || img.query || img.repoSocialPreview || "";
     if (seen.has(key)) return false;
     seen.add(key);
     return true;
   });
 
-  // Resolve images: path > url > query
+  // Resolve images: path > repoSocialPreview > url > query
+  // (repoSocialPreview takes priority over url so that an LLM that tacks an
+  // extra `url` field onto the social-preview entry doesn't accidentally make
+  // us fetch the github.com HTML page instead of the crawler's PNG.)
   const images: AssetManifest["images"] = [];
   for (let i = 0; i < uniqueImages.length; i++) {
     const img = uniqueImages[i];
@@ -59,32 +64,25 @@ export async function resolveAssets(
       }
     }
 
-    // 2. Remote URL
+    // 2. GitHub repo social preview (crawler returns JSON with base64 PNG)
+    if (img.repoSocialPreview) {
+      const resolved = await resolveRepoSocialPreview(img.repoSocialPreview, join(assetsDir, `repo-${i}.png`), `img-${i}`);
+      if (resolved) {
+        images.push(resolved);
+        continue;
+      }
+    }
+
+    // 3. Remote URL
     if (img.url) {
-      try {
-        const resp = await fetch(img.url);
-        if (resp.ok) {
-          const buf = Buffer.from(await resp.arrayBuffer());
-          const urlExt = img.url.endsWith(".png") ? ".png" : ".jpg";
-          const filename = `image-${i}${urlExt}`;
-          const localPath = join(assetsDir, filename);
-          await writeFile(localPath, buf);
-          images.push({
-            id: `img-${i}`,
-            localPath,
-            sourceUrl: img.url,
-            url: img.url,
-            query: img.query,
-            altText: img.query,
-          });
-          continue;
-        }
-      } catch {
-        // Fall through to query fallback
+      const fetched = await fetchUrlImage(img.url, join(assetsDir, `image-${i}${inferUrlExtension(img.url)}`), `img-${i}`, img.query);
+      if (fetched) {
+        images.push(fetched);
+        continue;
       }
     }
 
-    // 3. Query keyword (placeholder)
+    // 4. Query keyword (placeholder)
     if (img.query) {
       images.push({
         id: `img-${i}`,
@@ -114,7 +112,8 @@ export async function resolveAssets(
         (a) =>
           (firstImg.path && a.path === firstImg.path) ||
           (firstImg.url && a.url === firstImg.url) ||
-          (firstImg.query && a.query === firstImg.query)
+          (firstImg.query && a.query === firstImg.query) ||
+          (firstImg.repoSocialPreview && a.repoSocialPreview === firstImg.repoSocialPreview)
       );
       if (resolved) {
         return { id: `bg-${i}`, localPath: resolved.localPath };
@@ -141,3 +140,127 @@ export async function resolveAssets(
     ],
   };
 }
+
+/** Derive a file extension from a remote URL. Falls back through the URL
+ *  pathname's extension and then the last path segment (handles endpoints like
+ *  https://api.star-history.com/svg?repos=... where "svg" is the resource name
+ *  but there is no dot extension). Defaults to .jpg. */
+function inferUrlExtension(url: string): string {
+  const known = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif"]);
+  try {
+    const pathname = new URL(url).pathname;
+    const ext = extname(pathname).toLowerCase();
+    if (known.has(ext)) {
+      return ext === ".jpeg" ? ".jpg" : ext;
+    }
+    // No dot extension — try the last path segment (e.g. /svg → svg).
+    const seg = pathname.split("/").filter(Boolean).pop() ?? "";
+    if (seg && known.has(`.${seg}`)) {
+      return seg === "jpeg" ? ".jpg" : `.${seg}`;
+    }
+  } catch {
+    // ignore malformed URLs
+  }
+  return ".jpg";
+}
+
+/**
+ * Fetch a GitHub repo's social preview image via the crawler, decode the
+ * base64 PNG and write it to `destPath`. Returns a resolved asset entry on
+ * success, or `null` (with a console warning) on any failure so the caller
+ * can fall through to the next strategy. Trims whitespace from `repoKey`
+ * to absorb minor LLM formatting drift.
+ */
+async function resolveRepoSocialPreview(
+  repoKey: string,
+  destPath: string,
+  id: string,
+): Promise<AssetManifest["images"][number] | null> {
+  const trimmed = repoKey.trim();
+  const parts = trimmed.split("/").map((p) => p.trim()).filter(Boolean);
+  if (parts.length < 2) {
+    console.warn(`[assets] repoSocialPreview "${repoKey}" is not in "owner/name" form — skipping`);
+    return null;
+  }
+  // Use only the first two segments (some inputs arrive as "owner/name/extra").
+  const [owner, name] = parts;
+  const url = REPO_DETAIL_URL
+    .replace(":owner", encodeURIComponent(owner))
+    .replace(":repo", encodeURIComponent(name));
+
+  try {
+    const resp = await fetch(url);
+    if (!resp.ok) {
+      console.warn(`[assets] crawler ${url} returned ${resp.status} for repoSocialPreview "${repoKey}"`);
+      return null;
+    }
+    const json = (await resp.json()) as Record<string, any>;
+    const data = (json.data ?? json) as Record<string, any>;
+    const base64 = data.socialPreviewImageBase64;
+    if (typeof base64 !== "string" || base64.length === 0) {
+      console.warn(`[assets] crawler ${url} returned no socialPreviewImageBase64 for "${repoKey}"`);
+      return null;
+    }
+    const buf = Buffer.from(base64, "base64");
+    // PNG magic bytes: 89 50 4E 47 (\x89PNG). Sanity-check before writing so
+    // a malformed response doesn't produce a corrupt file that the renderer
+    // then fails to decode.
+    if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
+      console.warn(`[assets] crawler ${url} returned non-PNG socialPreviewImageBase64 for "${repoKey}"`);
+      return null;
+    }
+    await writeFile(destPath, buf);
+    return {
+      id,
+      localPath: destPath,
+      repoSocialPreview: trimmed,
+      altText: trimmed,
+    };
+  } catch (err) {
+    console.warn(`[assets] error fetching repoSocialPreview "${repoKey}":`, err instanceof Error ? err.message : err);
+    return null;
+  }
+}
+
+/**
+ * Fetch a remote image URL and write it to `destPath`. Only attempts when the
+ * URL is well-formed (http/https) so an LLM that puts a bare "owner/name"
+ * string into a `url` field doesn't make us try to fetch a relative path.
+ * Returns a resolved asset entry on success, or `null` (with a console
+ * warning on unexpected failures) so the caller can fall through.
+ */
+async function fetchUrlImage(
+  url: string,
+  destPath: string,
+  id: string,
+  query?: string,
+): Promise<AssetManifest["images"][number] | null> {
+  if (!/^https?:\/\//i.test(url)) {
+    console.warn(`[assets] skipping non-http(s) url: ${url}`);
+    return null;
+  }
+  try {
+    const resp = await fetch(url);
+    if (!resp.ok) {
+      console.warn(`[assets] ${url} returned ${resp.status}`);
+      return null;
+    }
+    const buf = Buffer.from(await resp.arrayBuffer());
+    if (buf.length === 0) {
+      console.warn(`[assets] ${url} returned empty body`);
+      return null;
+    }
+    await writeFile(destPath, buf);
+    return {
+      id,
+      localPath: destPath,
+      sourceUrl: url,
+      url,
+      query,
+      altText: query,
+    };
+  } catch (err) {
+    console.warn(`[assets] error fetching url ${url}:`, err instanceof Error ? err.message : err);
+    return null;
+  }
+}

+ 6 - 2
packages/core/src/stages/compose.ts

@@ -56,13 +56,17 @@ export function composeProject(
             (a) =>
               (img.path && a.path === img.path) ||
               (img.url && a.url === img.url) ||
-              (img.query && a.query === img.query)
+              (img.query && a.query === img.query) ||
+              (img.repoSocialPreview && a.repoSocialPreview === img.repoSocialPreview)
           );
-          return resolved ? { url: img.url, query: img.query, localPath: resolved.localPath } : undefined;
+          return resolved
+            ? { url: img.url, query: img.query, localPath: resolved.localPath }
+            : undefined;
         })
         .filter((x): x is NonNullable<typeof x> => !!x),
       backgroundAsset,
       layoutHint: scene.layoutHint ?? "centered",
+      github: scene.github,
     };
   });
 

+ 49 - 2
packages/core/src/stages/parse.ts

@@ -5,6 +5,7 @@ import {
   LLMClient,
   getParsePrompt,
   stripUnsupportedGlyphs,
+  clipToLength,
 } from "@pipeline/shared";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
@@ -18,6 +19,36 @@ export interface ParseStageConfig {
   source?: string;
 }
 
+/**
+ * Clip LLM-generated text fields to their schema-enforced maximums before
+ * validation. Clips at a sentence/clause boundary when possible (see
+ * clipToLength) so the result still reads naturally. Mutates a copy and
+ * returns it; the input is left untouched.
+ */
+function applyLengthLimits(input: unknown): unknown {
+  if (!input || typeof input !== "object") return input;
+  const root = (input as any).scenes && Array.isArray((input as any).scenes)
+    ? { ...(input as any) }
+    : input;
+  if (!Array.isArray((root as any).scenes)) return input;
+
+  (root as any).scenes = (root as any).scenes.map((scene: any) => {
+    if (!scene || typeof scene !== "object") return scene;
+    const next: any = { ...scene };
+    next.narration = clipToLength(scene.narration, 200);
+    if (scene.github && typeof scene.github === "object") {
+      next.github = {
+        ...scene.github,
+        highlights: clipToLength(scene.github.highlights, 30),
+        intro: clipToLength(scene.github.intro, 60),
+        review: clipToLength(scene.github.review, 30),
+      };
+    }
+    return next;
+  });
+  return root;
+}
+
 /** Strip emoji/decorative glyphs from every visible-text field of a VideoInput. */
 function sanitizeVideoInput(input: VideoInput): VideoInput {
   const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
@@ -76,7 +107,7 @@ export async function parseText(
   } else {
     const client = new LLMClient(config.llm);
     const systemPrompt = getParsePrompt(
-      template as "news" | "knowledge" | "opinion" | "marketing",
+      template as "news" | "knowledge" | "opinion" | "marketing" | "github-trending",
       config.source
     );
     const rawResponse = await client.chat(systemPrompt, text);
@@ -96,6 +127,11 @@ export async function parseText(
       );
     }
 
+    // LLMs routinely overshoot the documented character budgets (narration
+    // ≤200, github highlights/intro/review ≤30/60/30). Clip in place before
+    // schema validation so the pipeline tolerates drift instead of hard-failing.
+    aiParsed = applyLengthLimits(aiParsed);
+
     const validationResult = VideoInputSchema.safeParse(aiParsed);
     if (!validationResult.success) {
       throw new Error(
@@ -118,13 +154,23 @@ export async function parseText(
   const firstScene = videoInput.scenes[0];
   const coverKeyframes =
     coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3);
+  // For the auto-derived cover, only inherit a "real" image (path / url / query)
+  // from the first content scene — and only for templates that actually have
+  // usable hero imagery. For github-trending the first scene's images are
+  // repo-social-preview / star-history chart refs that are meaningless as a
+  // full-screen cover background, so we skip inheritance entirely and let the
+  // cover fall back to the template's default background.
+  const inheritCoverImage = template !== "github-trending";
+  const firstSceneCoverImage = inheritCoverImage
+    ? (firstScene?.images ?? []).find((img) => img.path || img.url || img.query)
+    : undefined;
   const coverImages = coverInput
     ? [
         ...(coverInput.imagePath ? [{ path: coverInput.imagePath }] : []),
         ...(coverInput.imageUrl ? [{ url: coverInput.imageUrl }] : []),
         ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
       ]
-    : (firstScene?.images ?? []).slice(0, 1);
+    : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
   scenes.push({
     id: "cover",
     index: sceneIndex++,
@@ -151,6 +197,7 @@ export async function parseText(
       duration: s.duration,
       speed: s.speed,
       layoutHint: s.layoutHint,
+      github: s.github,
     });
   }
 

+ 2 - 1
packages/shared/src/constants.ts

@@ -1,4 +1,4 @@
-export type TemplateType = "news" | "knowledge" | "opinion" | "marketing";
+export type TemplateType = "news" | "knowledge" | "opinion" | "marketing" | "github-trending";
 
 export type PlatformPreset =
   | "bilibili"
@@ -29,6 +29,7 @@ export const TEMPLATE_TYPES: TemplateType[] = [
   "knowledge",
   "opinion",
   "marketing",
+  "github-trending",
 ];
 
 export const PLATFORM_PRESET_KEYS: PlatformPreset[] = [

+ 1 - 1
packages/shared/src/index.ts

@@ -14,4 +14,4 @@ export type {
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } from "./llm/index.js";
 export type { LLMClientConfig, InputFormat, DetectResult } from "./llm/index.js";
-export { stripUnsupportedGlyphs, formatCount } from "./utils/index.js";
+export { stripUnsupportedGlyphs, clipToLength, formatCount } from "./utils/index.js";

+ 34 - 0
packages/shared/src/llm/prompts/parse-text.ts

@@ -19,6 +19,8 @@ The output JSON must follow this exact schema:
       "keyframes": [
         { "type": "text|highlight|image|icon|transition", "content": "string" }
       ],
+      "images": "array — optional, image references. Entries may use { path } for local files, { url } for remote URLs, or { repoSocialPreview: \"owner/name\" } for GitHub repo social preview cards (only used by github-trending template).",
+      "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license }, highlights, intro, review }. See template-specific rules.",
       "durationHint": 10,
       "backgroundQuery": "string — describe the ideal background image",
       "layoutHint": "full-text|split|centered|bullet-list"
@@ -83,6 +85,38 @@ Template: Product Marketing (产品营销)
 - Middle scenes: product features, benefits, social proof
 - Last scene: strongest benefit or proof (do NOT add CTA — outro handles CTA)
 - layoutHint preference: "split" for features, "centered" for highlights`,
+
+    "github-trending": `
+Template: GitHub Trending Showcase (GitHub 每日热榜)
+- Use a pragmatic, factual tone for a developer audience — no hype, no marketing fluff.
+- Each repository produces EXACTLY ONE scene.
+- For multi-repo inputs, optionally open with a 1-sentence trend observation woven into the first repo's narration, but do not create a separate trend-overview scene.
+- The output for this template relies on structured metadata embedded in the input. Treat that metadata as authoritative — DO NOT paraphrase or invent values.
+
+PER-REPO SCENE STRUCTURE (mandatory for every repo scene):
+
+displayText: the repo's fullName (e.g. "facebook/react"). Nothing else.
+
+github.repo: COPY the contents of the corresponding "<!-- repo-meta: ... -->" block from the input VERBATIM into scene.github.repo. Do not drop fields, do not rename keys, do not reformat numbers (keep stars as an integer). If license is empty string, keep it as empty string — the template will skip the license tag.
+
+images: array of EXACTLY TWO entries, copied from the corresponding "<!-- repo-images: ... -->" block:
+  1. { "repoSocialPreview": "owner/name" }  — DO NOT expand this into a URL. The assets stage resolves it.
+  2. { "url": "https://api.star-history.com/svg?repos=owner/name&type=Date" }  — copy the starHistory URL verbatim.
+Order matters: social preview first, star history second.
+
+github.highlights (项目亮点): one short phrase, ≤ 30 Chinese characters, naming the single most distinctive technical capability or signal. Example: "声明式 UI + 虚拟 DOM,生态最大". No filler ("这是一个强大的..."), no marketing adjectives, no language mention unless language IS the value proposition.
+
+github.intro (项目介绍): one sentence, ≤ 60 Chinese characters, stating concretely what the project does, the problem it solves, and one distinguishing mechanism. Pull specifics from the README. Example: "用 Rust 实现的 JavaScript 运行时,启动比 Node 快数倍,原生支持 TypeScript 与 npm 生态".
+
+github.review (点评推荐): one short recommendation, ≤ 30 Chinese characters, naming the target user / scenario or a one-line verdict. Example: "适合追求极致启动速度的 CLI 与服务端场景". Stay neutral — avoid superlatives like "最强" or "神器".
+
+narration (口播): ≤ 200 characters. Cover what the project does (deeper than intro), why it is notable today (timing / trend / adoption), and one distinctive characteristic a developer cares about. Pack specifics — numbers, comparisons, concrete use cases. Do NOT include a language bullet unless language is core to the value; do NOT read out the GitHub URL.
+
+CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout. If content does not fit, drop the least important detail rather than compressing into a longer sentence.
+
+Number formatting: stars/forks/etc. always render with lowercase k suffix when ≥ 1000 (e.g. 1234 → "1.2k", 12345 → "12.3k", 123456 → "123k"). Values under 1000 stay as plain integers. The collector already formats them in markdown text, but when you write counts into github.highlights / intro / review / narration yourself, apply the same rule.
+
+FORBIDDEN: emoji, arrows, dingbats, decorative unicode. Plain CJK + ASCII only.`,
   };
 
   const githubProjectIntroRule = `

+ 1 - 0
packages/shared/src/types/asset.ts

@@ -7,6 +7,7 @@ export const ResolvedAssetSchema = z.object({
   sourceUrl: z.string().optional(),
   url: z.string().optional(),
   query: z.string().optional(),
+  repoSocialPreview: z.string().optional(),
   altText: z.string().optional(),
   width: z.number().optional(),
   height: z.number().optional(),

+ 4 - 0
packages/shared/src/types/index.ts

@@ -9,6 +9,8 @@ export {
   VideoInputSchema,
   SceneSchema,
   ParsedContentSchema,
+  RepoMetaSchema,
+  GithubSceneDataSchema,
 } from "./scene.js";
 export type {
   WordTimestamp,
@@ -21,6 +23,8 @@ export type {
   VideoInput,
   Scene,
   ParsedContent,
+  RepoMeta,
+  GithubSceneData,
 } from "./scene.js";
 
 export { TTSSynthesizeRequestSchema, TTSResultSchema, VoiceInfoSchema, VoiceFilterSchema } from "./tts.js";

+ 4 - 3
packages/shared/src/types/pipeline.ts

@@ -1,9 +1,9 @@
 import { z } from "zod";
-import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema } from "./scene.js";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema } from "./scene.js";
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
-  template: z.enum(["news", "knowledge", "opinion", "marketing"]),
+  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending"]),
   platforms: z.array(z.enum([
     "bilibili",
     "douyin-long",
@@ -74,6 +74,7 @@ export const ComposedSceneSchema = z.object({
     })
     .optional(),
   layoutHint: z.string(),
+  github: GithubSceneDataSchema.optional(),
 });
 
 export type ComposedScene = z.infer<typeof ComposedSceneSchema>;
@@ -86,7 +87,7 @@ export const ComposedProjectSchema = z.object({
   durationInFrames: z.number(),
   audioPath: z.string(),
   scenes: z.array(ComposedSceneSchema),
-  template: z.enum(["news", "knowledge", "opinion", "marketing"]),
+  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending"]),
   platform: z.string(),
   title: z.string().optional(),
   subtitle: z.string().optional(),

+ 27 - 0
packages/shared/src/types/scene.ts

@@ -26,10 +26,35 @@ export const SceneImageSchema = z.object({
   path: z.string().optional(),
   url: z.string().optional(),
   query: z.string().optional(),
+  repoSocialPreview: z.string().optional(),
 });
 
 export type SceneImage = z.infer<typeof SceneImageSchema>;
 
+// --- GitHub template structured data ---
+
+export const RepoMetaSchema = z.object({
+  owner: z.string(),
+  name: z.string(),
+  fullName: z.string(),
+  language: z.string().optional(),
+  languageColor: z.string().optional(),
+  stars: z.number().optional(),
+  forks: z.number().optional(),
+  license: z.string().optional(),
+});
+
+export type RepoMeta = z.infer<typeof RepoMetaSchema>;
+
+export const GithubSceneDataSchema = z.object({
+  repo: RepoMetaSchema,
+  highlights: z.string().max(30),
+  intro: z.string().max(60),
+  review: z.string().max(30),
+});
+
+export type GithubSceneData = z.infer<typeof GithubSceneDataSchema>;
+
 export const VideoCoverSchema = z.object({
   imagePath: z.string().optional(),
   imageUrl: z.string().optional(),
@@ -69,6 +94,7 @@ export const InputSceneSchema = z.object({
   layoutHint: z
     .enum(["full-text", "split", "centered", "bullet-list"])
     .optional(),
+  github: GithubSceneDataSchema.optional(),
 });
 
 export type InputScene = z.infer<typeof InputSceneSchema>;
@@ -104,6 +130,7 @@ export const SceneSchema = z.object({
   layoutHint: z
     .enum(["full-text", "split", "centered", "bullet-list"])
     .optional(),
+  github: GithubSceneDataSchema.optional(),
 });
 
 export type Scene = z.infer<typeof SceneSchema>;

+ 1 - 1
packages/shared/src/utils/index.ts

@@ -1,2 +1,2 @@
-export { stripUnsupportedGlyphs } from "./sanitize-text.js";
+export { stripUnsupportedGlyphs, clipToLength } from "./sanitize-text.js";
 export { formatCount } from "./format.js";

+ 26 - 0
packages/shared/src/utils/sanitize-text.ts

@@ -19,3 +19,29 @@ export function stripUnsupportedGlyphs(text: string): string {
   if (typeof text !== "string" || text.length === 0) return text;
   return text.replace(UNSUPPORTED_GLYPH_PATTERN, "");
 }
+
+/**
+ * Clip a string to at most `max` characters. When the source exceeds the
+ * limit, prefer cutting at the last sentence/clause terminator that falls
+ * within [60%, max] of the limit so the result reads as a complete thought.
+ * If no such boundary exists, fall back to a hard cut.
+ *
+ * Used to absorb LLM drift: the parse prompt asks for tight character budgets
+ * (e.g. narration ≤ 200) but models routinely overshoot, which would otherwise
+ * hard-fail Zod validation. Trimming here keeps the pipeline resilient.
+ */
+export function clipToLength(text: string | undefined, max: number): string | undefined {
+  if (typeof text !== "string" || text.length <= max) return text;
+  const window = text.slice(0, max);
+  const minBoundary = Math.round(max * 0.6);
+
+  const sentenceMatch = /^(.*[。!?!?…])/su.exec(window);
+  if (sentenceMatch && sentenceMatch[1].length >= minBoundary) {
+    return sentenceMatch[1];
+  }
+  const clauseMatch = /^(.*[,,、;;])/su.exec(window);
+  if (clauseMatch && clauseMatch[1].length >= minBoundary) {
+    return clauseMatch[1];
+  }
+  return window;
+}

+ 5 - 2
packages/templates/src/Root.tsx

@@ -1,11 +1,12 @@
 import React, { useEffect, useState } from "react";
 import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
 import type { TemplateType, AspectRatio } from "@pipeline/shared";
-import type { WordTimestamp } from "@pipeline/shared";
+import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
 import NewsScene from "./news/index";
 import KnowledgeScene from "./knowledge/index";
 import OpinionScene from "./opinion/index";
 import MarketingScene from "./marketing/index";
+import GithubTrendingScene from "./github-trending/index";
 import { THEMES } from "./base/theme/colors";
 
 export interface RemotionScene {
@@ -28,6 +29,7 @@ export interface RemotionScene {
   audioFilename: string;
   backgroundAsset?: { id: string; filename: string };
   layoutHint: string;
+  github?: GithubSceneData;
 }
 
 export interface RemotionProps {
@@ -53,6 +55,7 @@ const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
   knowledge: KnowledgeScene,
   opinion: OpinionScene,
   marketing: MarketingScene,
+  "github-trending": GithubTrendingScene,
 };
 
 const RATIO_ID: Record<AspectRatio, string> = {
@@ -65,7 +68,7 @@ const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
   "9:16": { width: 1080, height: 1920 },
 };
 
-const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing"];
+const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
 
 const TemplateComposition: React.FC<RemotionProps> = (props) => {
   const SceneComponent = SCENE_MAP[props.template];

+ 29 - 0
packages/templates/src/base/theme/colors.ts

@@ -39,7 +39,36 @@ export const THEMES = {
     textMuted: "#a1a1aa",
     gradient: ["#0a0a0a", "#3b0764"],
   },
+  "github-trending": {
+    primary: "#22c55e",
+    primaryLight: "#4ade80",
+    accent: "#3b82f6",
+    bg: "#0f172a",
+    bgLight: "#1e293b",
+    text: "#ffffff",
+    textMuted: "#94a3b8",
+    gradient: ["#0f172a", "#1e3a8a"],
+  },
 } as const;
 
 export type ThemeName = keyof typeof THEMES;
 export type ThemeColors = (typeof THEMES)[ThemeName];
+
+/**
+ * Per-scene rotating background palette for the github-trending template.
+ * Indexed by `contentSceneIndex % PALETTE.length`. Each entry has a two-stop
+ * dark gradient and a lighter accent for tags/labels — all dark enough that
+ * white text reads cleanly on top.
+ */
+export const GITHUB_TRENDING_PALETTE = [
+  { from: "#0f172a", to: "#1e3a8a", accent: "#60a5fa" }, // navy
+  { from: "#042f2e", to: "#134e4a", accent: "#2dd4bf" }, // teal
+  { from: "#1e1b4b", to: "#4c1d95", accent: "#a78bfa" }, // indigo
+  { from: "#4a044e", to: "#9d174d", accent: "#f472b6" }, // magenta
+  { from: "#431407", to: "#9a3412", accent: "#fb923c" }, // orange
+  { from: "#14532d", to: "#15803d", accent: "#4ade80" }, // green
+  { from: "#0c4a6e", to: "#0369a1", accent: "#38bdf8" }, // sky
+  { from: "#3f1d11", to: "#7c2d12", accent: "#fb7185" }, // rust
+] as const;
+
+export type GithubTrendingPaletteEntry = (typeof GITHUB_TRENDING_PALETTE)[number];

+ 396 - 0
packages/templates/src/github-trending/index.tsx

@@ -0,0 +1,396 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  Img,
+  staticFile,
+  interpolate,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { Watermark } from "../base/components/watermark";
+import { formatCount } from "@pipeline/shared";
+
+interface GithubTrendingSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+  channelName?: string;
+}
+
+const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
+  scene,
+  sceneIndex,
+  title,
+  channelName,
+}) => {
+  const frame = useCurrentFrame();
+  const { width, height } = useVideoConfig();
+  const isPortrait = height > width;
+  const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
+  const github = scene.github;
+
+  const sceneDur = scene.endFrame - scene.startFrame;
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, sceneDur - 8, sceneDur],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  // Social preview image is images[0], star history is images[1] (per LLM
+  // prompt ordering — see packages/shared/src/llm/prompts/parse-text.ts).
+  const socialPreview = scene.images?.[0];
+  const starHistory = scene.images?.[1];
+
+  // -- Layout geometry (px) --
+  const pad = isPortrait ? 60 : 80;
+  const headerHeight = isPortrait ? 220 : 180;
+  const dividerY = pad + headerHeight;
+  const footerReserved = isPortrait ? 220 : 140;
+  const contentTop = dividerY + 24;
+  const contentBottom = height - footerReserved;
+  const contentHeight = contentBottom - contentTop;
+  const contentWidth = width - pad * 2;
+
+  const repo = github?.repo;
+  const language = repo?.language || "";
+  const languageColor = repo?.languageColor || palette.accent;
+  const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
+  const license = repo?.license || "";
+  const fullName = repo?.fullName || scene.displayText || "";
+
+  if (!github) {
+    return (
+      <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
+        <div style={{
+          position: "absolute", inset: 0, display: "flex",
+          alignItems: "center", justifyContent: "center", padding: pad,
+        }}>
+          <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
+            {scene.displayText || scene.narration}
+          </div>
+        </div>
+        {scene.wordTimestamps.length > 0 && <SubtitleBar wordTimestamps={scene.wordTimestamps} />}
+        <Watermark text={channelName} />
+      </AbsoluteFill>
+    );
+  }
+
+  const colGap = isPortrait ? 0 : 40;
+  const leftColWidth = isPortrait ? 0 : Math.round(contentWidth * 0.4);
+  const rightColWidth = isPortrait ? contentWidth : contentWidth - leftColWidth - colGap;
+
+  return (
+    <AbsoluteFill style={{
+      opacity: fadeOpacity,
+      background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
+    }}>
+      {/* Subtle grid overlay (matches other templates' aesthetic) */}
+      <div style={{
+        position: "absolute", inset: 0,
+        backgroundImage: `
+          linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
+          linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
+        `,
+        backgroundSize: "80px 80px",
+      }} />
+
+      {/* Top accent line */}
+      <div style={{
+        position: "absolute", top: 0, left: 0, width: "100%", height: 4,
+        background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
+      }} />
+
+      {/* Header: fullName + tags */}
+      <div style={{
+        position: "absolute",
+        top: pad,
+        left: pad,
+        right: pad,
+      }}>
+        <div style={{
+          fontSize: isPortrait ? 56 : 60,
+          fontWeight: 800,
+          color: "white",
+          fontFamily: "Noto Sans SC",
+          lineHeight: 1.15,
+          letterSpacing: "-0.01em",
+        }}>
+          {fullName}
+        </div>
+        <div style={{ display: "flex", flexWrap: "wrap", gap: 12, marginTop: 18 }}>
+          {language && <Tag accent={palette.accent}><ColorDot color={languageColor} />{language}</Tag>}
+          {starsLabel && <Tag accent={palette.accent}>{starsLabel}</Tag>}
+          {license && <Tag accent={palette.accent}>{license}</Tag>}
+        </div>
+      </div>
+
+      {/* Divider */}
+      <div style={{
+        position: "absolute",
+        top: dividerY,
+        left: pad,
+        right: pad,
+        height: 1,
+        background: "rgba(255,255,255,0.18)",
+      }} />
+
+      {/* Content: two columns (landscape) or stacked (portrait) */}
+      {isPortrait ? (
+        <PortraitContent
+          contentTop={contentTop}
+          contentHeight={contentHeight}
+          pad={pad}
+          contentWidth={contentWidth}
+          palette={palette}
+          socialPreview={socialPreview}
+          starHistory={starHistory}
+          github={github}
+        />
+      ) : (
+        <LandscapeContent
+          contentTop={contentTop}
+          contentHeight={contentHeight}
+          pad={pad}
+          contentWidth={contentWidth}
+          leftColWidth={leftColWidth}
+          colGap={colGap}
+          rightColWidth={rightColWidth}
+          palette={palette}
+          socialPreview={socialPreview}
+          starHistory={starHistory}
+          github={github}
+        />
+      )}
+
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      )}
+      <Watermark text={channelName} />
+    </AbsoluteFill>
+  );
+};
+
+// --- Sub-components ---
+
+const Tag: React.FC<{ accent: string; children: React.ReactNode }> = ({ accent, children }) => (
+  <div style={{
+    display: "inline-flex",
+    alignItems: "center",
+    gap: 8,
+    padding: "8px 18px",
+    borderRadius: 8,
+    background: "rgba(255,255,255,0.08)",
+    border: `1px solid ${accent}55`,
+    color: "white",
+    fontFamily: "Noto Sans SC",
+    fontSize: 22,
+    fontWeight: 500,
+    backdropFilter: "blur(4px)",
+  }}>
+    {children}
+  </div>
+);
+
+const ColorDot: React.FC<{ color: string }> = ({ color }) => (
+  <span style={{
+    display: "inline-block",
+    width: 10,
+    height: 10,
+    borderRadius: "50%",
+    background: color || "#ccc",
+  }} />
+);
+
+const ImageFrame: React.FC<{ children: React.ReactNode; minHeight?: number; flex?: number }> = ({ children, minHeight = 0, flex }) => (
+  <div style={{
+    flex: flex ?? 1,
+    minHeight,
+    background: "rgba(255,255,255,0.04)",
+    border: "1px solid rgba(255,255,255,0.10)",
+    borderRadius: 12,
+    overflow: "hidden",
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+    padding: 12,
+  }}>
+    {children}
+  </div>
+);
+
+const Section: React.FC<{
+  title: string;
+  accent: string;
+  body: string;
+  flex?: number;
+}> = ({ title, accent, body, flex }) => (
+  <div style={{
+    flex: flex ?? 1,
+    background: "rgba(255,255,255,0.05)",
+    border: "1px solid rgba(255,255,255,0.10)",
+    borderRadius: 12,
+    padding: "20px 24px",
+    display: "flex",
+    flexDirection: "column",
+    gap: 10,
+  }}>
+    <div style={{
+      display: "flex",
+      alignItems: "center",
+      gap: 10,
+    }}>
+      <span style={{
+        display: "inline-block",
+        width: 4,
+        height: 20,
+        background: accent,
+        borderRadius: 2,
+      }} />
+      <span style={{
+        fontSize: 22,
+        fontWeight: 700,
+        color: accent,
+        fontFamily: "Noto Sans SC",
+        letterSpacing: "0.04em",
+      }}>
+        {title}
+      </span>
+    </div>
+    <div style={{
+      fontSize: 26,
+      lineHeight: 1.5,
+      color: "white",
+      fontFamily: "Noto Sans SC",
+      fontWeight: 400,
+      overflow: "hidden",
+    }}>
+      {body}
+    </div>
+  </div>
+);
+
+interface ContentProps {
+  contentTop: number;
+  contentHeight: number;
+  pad: number;
+  contentWidth?: number;
+  leftColWidth?: number;
+  colGap?: number;
+  rightColWidth?: number;
+  palette: { from: string; to: string; accent: string };
+  socialPreview?: { filename: string };
+  starHistory?: { filename: string };
+  github: NonNullable<RemotionScene["github"]>;
+}
+
+const LandscapeContent: React.FC<ContentProps> = ({
+  contentTop, contentHeight, pad, contentWidth, leftColWidth, colGap, rightColWidth,
+  palette, socialPreview, starHistory, github,
+}) => {
+  const hasAnyImage = !!(socialPreview || starHistory);
+  return (
+    <div style={{
+      position: "absolute",
+      top: contentTop,
+      left: pad,
+      width: contentWidth,
+      height: contentHeight,
+      display: "flex",
+      gap: colGap,
+    }}>
+      {/* Left column: images stacked (hidden entirely when neither is present) */}
+      {hasAnyImage && (
+        <div style={{
+          width: leftColWidth,
+          height: contentHeight,
+          display: "flex",
+          flexDirection: "column",
+          gap: 20,
+        }}>
+          {socialPreview && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(socialPreview.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
+            </ImageFrame>
+          )}
+          {starHistory && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(starHistory.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
+            </ImageFrame>
+          )}
+        </div>
+      )}
+
+      {/* Right column: 3 sections (flex:1 fills full width when no images) */}
+      <div style={{
+        flex: 1,
+        height: contentHeight,
+        display: "flex",
+        flexDirection: "column",
+        gap: 20,
+      }}>
+        <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
+        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={1.4} />
+        <Section title="点评推荐" accent={palette.accent} body={github.review} />
+      </div>
+    </div>
+  );
+};
+
+const PortraitContent: React.FC<ContentProps> = ({
+  contentTop, contentHeight, pad, contentWidth, palette,
+  socialPreview, starHistory, github,
+}) => {
+  const hasAnyImage = !!(socialPreview || starHistory);
+  return (
+    <div style={{
+      position: "absolute",
+      top: contentTop,
+      left: pad,
+      width: contentWidth!,
+      height: contentHeight,
+      display: "flex",
+      flexDirection: "column",
+      gap: 20,
+    }}>
+      {/* Top row: images side-by-side (or single full-width; hidden when neither) */}
+      {hasAnyImage && (
+        <div style={{
+          height: Math.round(contentHeight * 0.42),
+          display: "flex",
+          gap: 20,
+        }}>
+          {socialPreview && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(socialPreview.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
+            </ImageFrame>
+          )}
+          {starHistory && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(starHistory.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
+            </ImageFrame>
+          )}
+        </div>
+      )}
+
+      {/* Bottom: 3 sections stacked (full height when no images) */}
+      <div style={{
+        flex: 1,
+        display: "flex",
+        flexDirection: "column",
+        gap: 16,
+      }}>
+        <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
+        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={1.4} />
+        <Section title="点评推荐" accent={palette.accent} body={github.review} />
+      </div>
+    </div>
+  );
+};
+
+export default GithubTrendingScene;

+ 212 - 0
test/fixtures/github-trending.json

@@ -0,0 +1,212 @@
+# GitHub Trending Today
+
+- **DeusData/codebase-memory-mcp**: High-performance code intelligence MCP server. Indexes codebases into a persistent knowledge graph — average repo in milliseconds. 158 languages, sub-ms queries, 99% fewer tokens. Single static binary, zero dependencies. (C, 8.6k stars)
+- **google-research/timesfm**: TimesFM (Time Series Foundation Model) is a pretrained time-series foundation model developed by Google Research for time-series forecasting. (Python, 24.2k stars)
+- **palmier-io/palmier-pro**: macOS video editor built for AI (Swift, 2.1k stars)
+- **koala73/worldmonitor**: Real-time global intelligence dashboard. AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface (TypeScript, 57.5k stars)
+- **aishwaryanr/awesome-generative-ai-guide**: A one stop repository for generative AI research updates, interview resources, notebooks and much more! (HTML, 27.7k stars)
+
+---
+
+## DeusData/codebase-memory-mcp
+
+High-performance code intelligence MCP server. Indexes codebases into a persistent knowledge graph — average repo in milliseconds. 158 languages, sub-ms queries, 99% fewer tokens. Single static binary, zero dependencies.
+<!-- repo-meta: {"owner":"DeusData","name":"codebase-memory-mcp","fullName":"DeusData/codebase-memory-mcp","language":"C","languageColor":"","stars":8590,"forks":653,"license":"MIT"} -->
+<!-- repo-images: {"socialPreview":"DeusData/codebase-memory-mcp","starHistory":"https://api.star-history.com/svg?repos=DeusData/codebase-memory-mcp&type=Date"} -->
+
+### README
+# codebase-memory-mcp
+
+[![GitHub Release](https://img.shields.io/github/v/release/DeusData/codebase-memory-mcp?style=flat&color=blue)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
+[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
+[![CI](https://img.shields.io/github/actions/workflow/status/DeusData/codebase-memory-mcp/dry-run.yml?label=CI)](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
+[![Tests](https://img.shields.io/badge/tests-5604_passing-brightgreen)](https://github.com/DeusData/codebase-memory-mcp)
+[![Languages](https://img.shields.io/badge/languages-158-orange)](https://github.com/DeusData/codebase-memory-mcp)
+[![Hybrid LSP](https://img.shields.io/badge/Hybrid_LSP-9_languages-blue)](#hybrid-lsp)
+[![Agents](https://img.shields.io/badge/agents-11-purple)](https://github.com/DeusData/codebase-memory-mcp)
+[![Pure C](https://img.shields.io/badge/pure_C-zero_dependencies-blue)](https://github.com/DeusData/codebase-memory-mcp)
+[![Platform](https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-supported-lightgrey)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
+[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)
+[![SLSA 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
+[![VirusTotal](https://img.shields.io/badge/VirusTotal-scanned_every_release-brightgreen?logo=virustotal)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
+[![arXiv](https://img.shields.io/badge/arXiv-2603.27277-b31b1b?logo=arxiv)](https://arxiv.org/abs/2603.27277)
+
+**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary for macOS, Linux, and Windows — 
+...
+
+---
+
+## google-research/timesfm
+
+TimesFM (Time Series Foundation Model) is a pretrained time-series foundation model developed by Google Research for time-series forecasting.
+<!-- repo-meta: {"owner":"google-research","name":"timesfm","fullName":"google-research/timesfm","language":"Python","languageColor":"","stars":24192,"forks":2286,"license":"Apache-2.0"} -->
+<!-- repo-images: {"socialPreview":"google-research/timesfm","starHistory":"https://api.star-history.com/svg?repos=google-research/timesfm&type=Date"} -->
+
+### README
+# TimesFM
+
+TimesFM (Time Series Foundation Model) is a pretrained time-series foundation
+model developed by Google Research for time-series forecasting.
+
+*   Paper:
+    [A decoder-only foundation model for time-series forecasting](https://arxiv.org/abs/2310.10688),
+    ICML 2024.
+*   All checkpoints:
+    [TimesFM Hugging Face Collection](https://huggingface.co/collections/google/timesfm-release-66e4be5fdb56e960c1e482a6).
+*   [Google Research blog](https://research.google/blog/a-decoder-only-foundation-model-for-time-series-forecasting/).
+*   TimesFM in Google 1P Products:
+    *   [BigQuery ML](https://cloud.google.com/bigquery/docs/timesfm-model): Enterprise level SQL queries for scalability and reliability.
+    *   [Google Sheets](https://workspaceupdates.googleblog.com/2026/02/forecast-data-in-connected-sheets-BigQueryML-TimesFM.html): For your daily spreadsheet. 
+    *   [Vertex Model Garden](https://pantheon.corp.google.com/vertex-ai/publishers/google/model-garden/timesfm): Dockerized endpoint for agentic calling.
+
+This open version is not an officially supported Google product.
+
+**Latest Model Version:** TimesFM 2.5
+
+**Archived Model Versions:**
+
+-   1.0 and 2.0: relevant code archived in the sub directory `v1`. You can `pip
+    install timesfm==1.3.0` to install an older version of this package to load
+    them.
+## Update - June 5, 2026
+
+Updated PyPI to `timesfm=2.0.0`. See [Install](https://github.com/google-research/timesfm#from-pypi).
+
+## Update - Apr. 9, 2026
+
+Added fine-tuning example using HuggingFace Transformers + PEFT (LoRA) — see
+[`timesfm-forecasting/examples/finetuning/`](timesfm-forecasting/examples/finetuning/).
+Also added unit tests (`tests/`) and incorporated several community fixes.
+
+Shoutout to [@kashif](https://github.com/kashif) and [@darkpowerxo](https://github.com/darkpowerxo). 
+
+## Update - Mar. 19, 2026
+
+Huge shoutout to [@borealBytes](https://github.com/borealBytes) for adding the support for [AGENTS](https://github.com/google-research
+...
+
+---
+
+## palmier-io/palmier-pro
+
+macOS video editor built for AI
+<!-- repo-meta: {"owner":"palmier-io","name":"palmier-pro","fullName":"palmier-io/palmier-pro","language":"Swift","languageColor":"","stars":2136,"forks":206,"license":"GPL-3.0"} -->
+<!-- repo-images: {"socialPreview":"palmier-io/palmier-pro","starHistory":"https://api.star-history.com/svg?repos=palmier-io/palmier-pro&type=Date"} -->
+
+### README
+<div align="center">
+
+# Palmier Pro
+
+**The video editor built for AI.**
+
+<a href="https://github.com/palmier-io/palmier-pro/releases/latest/download/PalmierPro.dmg">
+  <img src="./assets/macos-badge.png" alt="Download palmierpro for macOS" width="180" />
+</a>
+
+<sub><i>Requires macOS 26 (Tahoe) on Apple Silicon</i></sub>
+
+<a href="https://x.com/Palmier_io"><img src="https://img.shields.io/badge/Follow-%40Palmier__io-000000?style=flat&logo=x&logoColor=white" alt="Follow on X" /></a>
+<a href="https://discord.com/invite/SMVW6pKYmg"><img src="https://img.shields.io/badge/Join-Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Join Discord" /></a>
+<a href="https://www.ycombinator.com/companies/palmier"><img src="https://img.shields.io/badge/Y%20Combinator-S24-orange" alt="Y Combinator S24" /></a>
+
+</div>
+
+<img src="./assets/palmier-ui.png" alt="palmierpro UI" width="900" />
+
+---
+
+Palmier Pro is an open source video editor for Mac. You and your agent can generate and edit videos together inside the timeline.
+
+### Swift-native video editor
+
+We built Palmier Pro from scratch with Swift. The north star is Premiere Pro, with our take on integrating AI into the workflow.
+
+### Built-in Generative AI
+
+Generate videos and images with SOTA models like Seedance, Kling, Nano Banana Pro inside the timeline editor.
+
+### Integrates with your agents
+
+Connects your Claude/Codex/Cursor via MCP, or use the in-app agent to work on the same project together.
+
+## MCP server
+
+When the app is open, it exposes an MCP server at `http://127.0.0.1:19789/mcp` via HTTP. To connect:
+
+**Claude Code**
+```bash
+claude mcp add --transport http palmier-pro http://127.0.0.1:19789/mcp
+```
+
+**Codex**
+```bash
+codex mcp add palmier-pro --url http://127.0.0.1:19789/mcp
+```
+
+**Cursor**
+
+The easiest way is go inside the app `Help` -> `MCP Instructions` -> `Install in Cursor`, or install manually by adding this to `~/.cursor/mcp.json`:
+
+```
+{
+  "mcpServers": {
+    "palmier-pro": {
+      "type": "http",
+    
+...
+
+---
+
+## koala73/worldmonitor
+
+Real-time global intelligence dashboard. AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface
+<!-- repo-meta: {"owner":"koala73","name":"worldmonitor","fullName":"koala73/worldmonitor","language":"TypeScript","languageColor":"","stars":57471,"forks":9152,"license":"NOASSERTION"} -->
+<!-- repo-images: {"socialPreview":"koala73/worldmonitor","starHistory":"https://api.star-history.com/svg?repos=koala73/worldmonitor&type=Date"} -->
+
+### README
+# World Monitor
+
+**Real-time global intelligence dashboard** — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface.
+
+[![GitHub stars](https://img.shields.io/github/stars/koala73/worldmonitor?style=social)](https://github.com/koala73/worldmonitor/stargazers)
+[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white)](https://discord.gg/re63kWKxaz)
+[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
+[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
+[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main)
+[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest)
+
+<p align="center">
+  <a href="https://worldmonitor.app"><img src="https://img.shields.io/badge/Web_App-worldmonitor.app-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>&nbsp;
+  <a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>&nbsp;
+  <a href="https://finance.worldmonitor.app"><img src="https://img.shields.io/badge/Finance_Variant-finance.worldmonitor.app-059669?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Finance Variant"></a>&nbsp;
+  <a href="https://commodity.worldmonitor.app"><img src="https://img.shields.io/badge/Commodity_Variant-commodity.worldmonitor.app-b45309?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Commodity Variant"></a>&nbsp;
+  <a href="https://happy.worldmonitor.app"><img src="https://img.shields.io/badge/Happy_Variant-happy.worldmonitor.app-f59e0b?st
+...
+
+---
+
+## aishwaryanr/awesome-generative-ai-guide
+
+A one stop repository for generative AI research updates, interview resources, notebooks and much more!
+<!-- repo-meta: {"owner":"aishwaryanr","name":"awesome-generative-ai-guide","fullName":"aishwaryanr/awesome-generative-ai-guide","language":"HTML","languageColor":"","stars":27724,"forks":5744,"license":"MIT"} -->
+<!-- repo-images: {"socialPreview":"aishwaryanr/awesome-generative-ai-guide","starHistory":"https://api.star-history.com/svg?repos=aishwaryanr/awesome-generative-ai-guide&type=Date"} -->
+
+### README
+# :star: :bookmark: awesome-generative-ai-guide
+
+Generative AI is experiencing rapid growth, and this repository serves as a comprehensive hub for updates on generative AI research, interview materials, notebooks, and more!
+
+<a href="https://trendshift.io/repositories/7663" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7663" alt="aishwaryanr%2Fawesome-generative-ai-guide | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
+
+Explore the following resources:
+
+1. [Monthly Best GenAI Papers List](https://github.com/aishwaryanr/awesome-generative-ai-guide?tab=readme-ov-file#star-best-genai-papers-list-january-2024)
+2. [GenAI Interview Resources](https://github.com/aishwaryanr/awesome-generative-ai-guide?tab=readme-ov-file#computer-interview-prep)
+3. [Applied LLMs Mastery 2024 (created by Aishwarya Naresh Reganti) course material](https://github.com/aishwaryanr/awesome-generative-ai-guide?tab=readme-ov-file#ongoing-applied-llms-mastery-2024)
+4. [Generative AI Genius 2024 (created by Aishwarya Naresh Reganti) course material](https://github.com/aishwaryanr/awesome-generative-ai-guide/blob/main/free_courses/generative_ai_genius/README.md)
+5. [AI Evals for Everyone (created by Aishwarya Naresh Reganti & Kiriti Badam) - Get Certified!](https://github.com/aishwaryanr/awesome-generative-ai-guide/blob/main/free_courses/ai_evals_for_everyone/README.md)
+6. **[NEW] [OpenClaw Mastery for Everyone (created by Aishwarya Reganti & Kiriti Badam) - Get Certified!](https://github.com/aishwaryanr/awesome-generative-ai-guide/blob/main/free_courses/openclaw_mastery_for_everyone/README.md)**
+7. [List of all GenAI-related free courses (over 90 listed)](https://github.com/aishwaryanr/awesome-generative-ai-guide?tab=readme-ov-file#book-list-of-free-genai-courses)
+8. [List of code repositories/notebooks for developing generative AI applications](https://github.com/aishwaryanr/awesome-generative-ai-guide?tab=readme-ov-file#notebook-code-notebooks
+...