Procházet zdrojové kódy

feat(github-weekly): 新增 GitHub 周榜模板——采集/契约/提示词/模板/调度全链路

- collect: github-weekly 采集器(trending API 追加 since=weekly,按周涨星
  currentPeriodStars 降序取 top8 逐仓抓 README,复用 repo-meta 注释协议)
- shared: TemplateType 扩展 github-weekly;双榜共享 GithubBoardExtension
  契约(type 判别枚举);新增 formatWeekRange(周一→周日 zh-CN 周区间)
- text: assemble 双榜统一封面装配(周榜刊头「GitHub 周榜」+ 周区间副标题);
  parse-text 新增周榜提示词(trendSummary/publish 元信息/逐仓结构/周框架口播/禁问候语)
- templates: github-weekly 浅色杂志风内容场景(纸感背景+白色编辑卡片+命名图片
  socialPreview/starHistory);Root 路由与周榜封面接入;GITHUB_WEEKLY_PALETTE 配色
- renderer: compose/github-images 支持双榜 extension,同名图片按名解析
- web: 调度器新增 sourceArgs 透传(github-repo 等);create/templates 页接入新模板
- config: 周榜采集 URL/配色/发布元信息 + 每周六 08:50 定时任务(bilibili)

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey před 1 měsícem
rodič
revize
f975cc5f1b

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

@@ -9,6 +9,7 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
   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" },
+  "github-weekly": { label: "GitHub 周榜", desc: "Weekly board in a light magazine style", color: "#d97706" },
 };
 
 const PLATFORMS_INFO: Record<string, string> = {

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

@@ -31,6 +31,12 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
     color: "#22c55e",
     layouts: "Repo header + tags, two-column image/text body",
   },
+  "github-weekly": {
+    label: "GitHub 周榜",
+    desc: "The weekly board in a light magazine style — paper background, editorial rules, weekly star-gain tags and white section cards. Best for weekly roundups.",
+    color: "#d97706",
+    layouts: "Journal masthead cover, repo header + tags, white section cards",
+  },
 };
 
 export default function TemplatesPage() {
@@ -38,7 +44,7 @@ export default function TemplatesPage() {
     <div>
       <div className="page-header">
         <h1>Templates</h1>
-        <p>Choose from 5 professionally designed video templates, each with 16:9 and 9:16 variants</p>
+        <p>Choose from 6 professionally designed video templates, each with 16:9 and 9:16 variants</p>
       </div>
 
       <div className="card-grid">

+ 4 - 0
apps/web/src/lib/scheduler.ts

@@ -9,6 +9,8 @@ export interface ScheduleJob {
   platform?: string;
   /** Data source to collect before rendering (e.g. "github-trending"). */
   source?: string;
+  /** Extra collector args (e.g. { owner, repo } for github-repo). */
+  sourceArgs?: Record<string, string>;
   /** Upload OSS + push Feishu after render. Default true. */
   publish?: boolean;
   enabled?: boolean;
@@ -64,6 +66,7 @@ export function loadSchedules(): ScheduleJob[] {
       template: j.template,
       platform: j.platform || "bilibili",
       source: j.source,
+      sourceArgs: j.sourceArgs && typeof j.sourceArgs === "object" ? j.sourceArgs : undefined,
       publish: j.publish !== false, // default true
       enabled: true,
     });
@@ -123,6 +126,7 @@ async function fire(job: ScheduleJob): Promise<void> {
       platforms: [job.platform ?? "bilibili"],
       ttsProvider,
       source: job.source,
+      sourceArgs: job.sourceArgs,
       noPublish: !job.publish,
     });
     console.log(`[scheduler] job ${jobId.slice(0, 8)} started for ${job.template}/${job.platform}`);

+ 24 - 1
config/default.yaml

@@ -79,14 +79,23 @@ publishMeta:
     github-trending:
       tid: 122                  # 分区 id (122 = 科技→科普). Adjust to your target board.
       tags: ["GitHub", "开源", "热榜"]
+    github-weekly:
+      tid: 122
+      tags: ["GitHub", "开源", "周榜"]
   douyin-long:
     github-trending:
       category: "科技"
       tags: ["GitHub", "开源"]
+    github-weekly:
+      category: "科技"
+      tags: ["GitHub", "开源", "周榜"]
   douyin-short:
     github-trending:
       category: "科技"
       tags: ["GitHub", "开源"]
+    github-weekly:
+      category: "科技"
+      tags: ["GitHub", "开源", "周榜"]
 
 templates:
   news:
@@ -104,6 +113,9 @@ templates:
   github-trending:
     primaryColor: "#22c55e"
     accentColor: "#3b82f6"
+  github-weekly:
+    primaryColor: "#d97706"
+    accentColor: "#1e3a5f"
 
 collect:
   github-trending:
@@ -111,6 +123,10 @@ collect:
     # Repo-detail endpoint used to fetch each top repo's README (per-repo
     # description grounding). Optional — without it, only trending metadata is used.
     repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
+  # Weekly board — same endpoints; the collector appends ?since=weekly.
+  github-weekly:
+    url: "https://github.crawler.corp.shuidi.tech/api/trending"
+    repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
   github-repo:
     url: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
   github-daily:
@@ -122,9 +138,10 @@ collect:
 # 一个进程内调度器,到点 spawn CLI 渲染——复用与 WebUI/HTTP 完全相同的链路,
 # 任务同样进 jobs 列表、走 OSS/飞书发布。改配置后需重启容器生效。
 #   cron     — 5 字段 cron(分 时 日 月 周),按 TZ(默认 Asia/Shanghai=北京时间)
-#   template — 渲染模板(news|knowledge|opinion|marketing|github-trending)
+#   template — 渲染模板(news|knowledge|opinion|marketing|github-trending|github-weekly)
 #   platform — 平台规格(bilibili|douyin-long|douyin-short|...),默认 bilibili
 #   source   — 采集源(如 github-trending);定时场景一般用 source 自动取数
+#   sourceArgs — 采集器附加参数(如 github-repo 的 owner/repo),可选
 #   publish  — 是否上传 OSS + 推送飞书,默认 true
 #   enabled  — 是否启用该条,默认 true
 # 环境变量 SCHEDULES(JSON 数组,同结构)可整体覆盖(部署期改无需重建镜像);
@@ -136,3 +153,9 @@ schedules:
     source: "github-trending"    # 先采集当日 trending,再解析→渲染→发布
     publish: true
     enabled: true
+  - cron: "50 8 * * 6"          # 北京时间每周六 08:50
+    template: "github-weekly"
+    platform: "bilibili"
+    source: "github-weekly"      # 采集本周 trending(since=weekly),再解析→渲染→发布
+    publish: true
+    enabled: true

+ 122 - 0
packages/collect/src/collectors/github-weekly.ts

@@ -0,0 +1,122 @@
+import type { DataSource, CollectResult, CollectParams } from "../types.js";
+import { extractItems } from "../types.js";
+import { registerCollector } from "../registry.js";
+import { formatCount, type RepoMeta } from "@pipeline/shared";
+
+/** github-weekly = the github-trending flow pinned to since=weekly (the weekly
+ *  star-gain board). Same shape, same README enrichment; only the query param,
+ *  the markdown framing and the gain label differ. A monthly board later is a
+ *  copy of this file with SINCE="monthly" and adjusted labels. */
+const SINCE = "weekly";
+/** Which repos get their README fetched — mirrors github-trending (the text
+ *  module later takes the top 6 by weekly gain). */
+const README_FETCH_LIMIT = 8;
+const README_TRUNCATE = 2000;
+
+class GitHubWeeklyCollector implements DataSource {
+  readonly name = "github-weekly";
+  private url: string;
+  private repoUrlTemplate: string;
+
+  constructor(config?: Record<string, any>) {
+    this.url = config?.url ?? "";
+    this.repoUrlTemplate = config?.repoUrl ?? "";
+  }
+
+  async collect(_params?: CollectParams): Promise<CollectResult> {
+    if (!this.url) {
+      throw new Error("github-weekly collector requires 'url' in config");
+    }
+
+    const requestUrl = `${this.url}${this.url.includes("?") ? "&" : "?"}since=${SINCE}`;
+    const response = await fetch(requestUrl);
+    if (!response.ok) {
+      throw new Error(`GitHub weekly trending API error: ${response.status} ${await response.text()}`);
+    }
+
+    const raw = await response.json();
+    const items = extractItems(raw);
+
+    // Rank by weekly star gain (desc) and keep the top slice we will describe.
+    // currentPeriodStars is period-relative — under since=weekly it IS the
+    // weekly gain (RepoMeta.todayStars carries it downstream unchanged).
+    const ranked = [...items]
+      .sort((a, b) => (b.currentPeriodStars ?? 0) - (a.currentPeriodStars ?? 0))
+      .slice(0, README_FETCH_LIMIT);
+
+    // Fetch each repo's README in parallel (best-effort; failures omit it).
+    const enriched = await Promise.all(
+      ranked.map(async (repo) => ({ repo, readme: await this.fetchReadme(repo) }))
+    );
+
+    const lines: string[] = ["# GitHub Weekly Trending\n"];
+    const repos: RepoMeta[] = [];
+
+    for (const { repo, readme } of enriched) {
+      const owner = repo.author ?? "";
+      const name = repo.name ?? "";
+      const fullName = repo.fullName || `${owner}/${name}`;
+      if (!owner || !name) continue;
+
+      const meta: RepoMeta = {
+        owner,
+        name,
+        fullName,
+        language: repo.language ?? "",
+        languageColor: repo.languageColor ?? "",
+        stars: repo.stars,
+        forks: repo.forks,
+        license: "",
+        todayStars: repo.currentPeriodStars ?? undefined,
+      };
+      repos.push(meta);
+
+      lines.push(`## ${fullName}`);
+      if (repo.description) lines.push(`${repo.description}`);
+
+      // Same legacy repo-meta comment protocol as github-trending — keeps the
+      // shared parse prompt's COPY-VERBATIM rule working for both boards.
+      lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+
+      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(`Week: +${formatCount(repo.currentPeriodStars)}`);
+      if (repo.url) meta2.push(`URL: ${repo.url}`);
+      if (meta2.length) {
+        lines.push(meta2.map((m) => `- ${m}`).join("\n"));
+      }
+
+      if (readme) {
+        const body = readme.length > README_TRUNCATE ? readme.slice(0, README_TRUNCATE) + "\n..." : readme;
+        lines.push(`\n### README\n${body}`);
+      }
+      lines.push("");
+    }
+
+    return { type: "text", content: lines.join("\n"), repos };
+  }
+
+  /** Best-effort README fetch via the repo-detail endpoint. Returns null on any
+   *  failure so a single repo's lookup problem never breaks the whole collect. */
+  private async fetchReadme(repo: any): Promise<string | null> {
+    if (!this.repoUrlTemplate) return null;
+    const owner = repo.author ?? "";
+    const name = repo.name ?? "";
+    if (!owner || !name) return null;
+    const url = this.repoUrlTemplate
+      .replace(":owner", encodeURIComponent(owner))
+      .replace(":repo", encodeURIComponent(name));
+    try {
+      const res = await fetch(url);
+      if (!res.ok) return null;
+      const raw = (await res.json()) as any;
+      const data = raw.data ?? raw;
+      return typeof data.readme === "string" ? data.readme : null;
+    } catch {
+      return null;
+    }
+  }
+}
+
+registerCollector("github-weekly", (config) => new GitHubWeeklyCollector(config));

+ 1 - 0
packages/collect/src/index.ts

@@ -3,5 +3,6 @@ export { extractItems } from "./types.js";
 export { getCollector, listCollectorNames, registerCollector } from "./registry.js";
 
 import "./collectors/github-trending.js";
+import "./collectors/github-weekly.js";
 import "./collectors/github-repo.js";
 import "./collectors/github-daily.js";

+ 4 - 3
packages/renderer/src/compose.ts

@@ -44,10 +44,11 @@ export function composeRenderProps(
       })
       .filter((x): x is NonNullable<typeof x> => !!x);
 
-    // Resolve the per-template extension (github-trending images → filenames).
-    const extension = seg.extension?.type === "github-trending"
+    // Resolve the per-template extension (github board images → filenames;
+    // github-trending and github-weekly share the shape).
+    const extension = seg.extension?.type === "github-trending" || seg.extension?.type === "github-weekly"
       ? {
-          type: "github-trending" as const,
+          type: seg.extension.type,
           repo: seg.extension.repo,
           highlights: seg.extension.highlights,
           intro: seg.extension.intro,

+ 3 - 3
packages/renderer/src/github-images.ts

@@ -1,4 +1,4 @@
-import type { VideoDocument, AssetManifest, RenderGithubImages, GithubTrendingExtension } from "@pipeline/shared";
+import type { VideoDocument, AssetManifest, RenderGithubImages, GithubBoardExtension } from "@pipeline/shared";
 import { basename } from "node:path";
 
 /**
@@ -25,7 +25,7 @@ export interface ResolvableImage {
  */
 export function segmentImageRefs(seg: VideoDocument["data"][number]): ResolvableImage[] {
   const refs: ResolvableImage[] = (seg.images ?? []).map((i) => ({ ...i }));
-  if (seg.extension?.type === "github-trending") {
+  if (seg.extension?.type === "github-trending" || seg.extension?.type === "github-weekly") {
     const imgs = seg.extension.images;
     if (imgs?.socialPreview) refs.push({ repoSocialPreview: imgs.socialPreview });
     if (imgs?.starHistory) refs.push({ url: imgs.starHistory });
@@ -40,7 +40,7 @@ export function segmentImageRefs(seg: VideoDocument["data"][number]): Resolvable
  * resolved. `ext` is the contract (refs); `assets` is the resolved manifest.
  */
 export function resolveGithubImages(
-  ext: GithubTrendingExtension,
+  ext: GithubBoardExtension,
   assets: AssetManifest
 ): RenderGithubImages | undefined {
   const refs = ext.images;

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

@@ -1,4 +1,4 @@
-export type TemplateType = "news" | "knowledge" | "opinion" | "marketing" | "github-trending";
+export type TemplateType = "news" | "knowledge" | "opinion" | "marketing" | "github-trending" | "github-weekly";
 
 export type PlatformPreset =
   | "bilibili"
@@ -30,6 +30,7 @@ export const TEMPLATE_TYPES: TemplateType[] = [
   "opinion",
   "marketing",
   "github-trending",
+  "github-weekly",
 ];
 
 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, LLMResult, InputFormat, DetectResult } from "./llm/index.js";
-export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, normalizeCountsForTTS, audioFilenameFor, DEFAULT_TIMEZONE } from "./utils/index.js";
+export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, formatWeekRange, normalizeCountsForTTS, audioFilenameFor, DEFAULT_TIMEZONE } from "./utils/index.js";

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

@@ -128,6 +128,48 @@ CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout. If content doe
 
 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.`,
+
+    "github-weekly": `
+Template: GitHub Weekly Board (GitHub 周榜)
+- Use a pragmatic, factual tone for a developer audience — no hype, no marketing fluff. Slightly more reflective than the daily board: these are the week's biggest gainers, so emphasize momentum and staying power over novelty.
+- Each repository produces EXACTLY ONE scene.
+- Do not create overview, summary, or trend-overview SCENES. The opening title screen (cover) is built by the pipeline; it does not need a scene. Instead, convey the week's overall theme via the two top-level cover metadata fields below. Each repo scene MUST focus on its own repo.
+- The output for this template relies on structured metadata embedded in the input. Treat that metadata as authoritative — DO NOT paraphrase or invent values.
+
+COVER METADATA (github-weekly only — emit this TOP-LEVEL field alongside title/summary; omit it for every other template):
+- trendSummary: one short sentence (≤30 chars) synthesizing the overall trend across this week's repos — what theme gained momentum or held the board. Must read as an editorial insight, NOT a restatement of the repo list or a generic filler. Example: "AI Agent 与代码智能工具持续升温". Plain CJK/ASCII only.
+
+PUBLISH METADATA (github-weekly only — emit this TOP-LEVEL \`publish\` object alongside title/summary; omit it for every other template). These fields become the video's posting metadata on the target platform (Bilibili/Douyin 简介 with 标签), NOT anything shown in the video itself:
+- publish.title: a catchy POSTING title for the video page. It MUST be distinct from the fixed cover masthead "GitHub 周榜" — weave in the week's overall trend or the single most notable repo. ≤ 40 chars. Example: "GitHub 周榜:AI Agent 与代码工具持续升温". Plain CJK/ASCII only.
+- publish.description: a 1–3 sentence video 简介 summarizing which repos/themes this week's video covers and why a developer should watch. ≤ 200 chars. Plain CJK/ASCII only.
+- publish.tags: 3–8 话题 tags as an array of short strings, each ≤ 12 chars (e.g. ["GitHub","开源","周榜","AI Agent","编程"]). Plain CJK/ASCII only.
+
+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. If the repo-meta block omits todayStars but the repo's text contains a "Week: +N" line, extract the integer N and set github.repo.todayStars to it (under the weekly board, todayStars carries the WEEKLY star gain).
+
+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 (项目介绍): a DETAILED, multi-sentence project description, up to 200 Chinese characters. Aim for the upper end of the budget — the goal is comprehensive coverage, not brevity. Cover: what the project actually does (concrete capabilities, not category labels), the problem it solves and for whom, 1-2 distinguishing mechanisms or technical choices (e.g. "用 Rust 实现", "采用 CRDT 算法", "基于 MCP 协议"), notable ecosystem signals (contributors, recent activity, adoption) if space allows, and the typical workflow or integration pattern. Pull specifics from the README — names of features, numbers, comparisons — rather than restating the headline. Stay factual; do NOT include a language bullet unless language IS the value proposition.
+
+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 THIS WEEK (momentum / sustained growth / what the weekly gain signals), and one distinctive characteristic a developer cares about. Week-framing is welcome ("本周涨星最快", "这一周里…") but keep it factual. 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.
+
+GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with a greeting ("大家好,…"), so EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
+- A greeting: "大家好", "各位", "哈喽", "嗨", time-of-day greetings.
+- A show-opener that references the day/week, show, or format: "本周热榜速览", "今天为大家带来...", "本周为大家带来...", "本期节目...", "欢迎收看...".
+- Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是一款声明式 UI 库..."). Mentioning the week INSIDE the narration (not as an opener) is fine.
+
+PROJECT NAME RULE: in narration, refer to each project by its SHORT repo name only (e.g. "React", "next.js", "bun"). NEVER read the owner/author prefix ("Facebook", "openai", "facebook/react") — the owner already appears on the GitHub 地址 card, and reading it aloud in a brief intro adds no information and can cause ambiguity. This applies to narration only; displayText and keyframe cards keep the owner/name form.
+
+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. Weekly gain values come from the "Week: +N" lines — when you cite them in narration, say the number naturally (e.g. "本周涨了 1.2 万星").
+
 FORBIDDEN: emoji, arrows, dingbats, decorative unicode. Plain CJK + ASCII only.`,
   };
 
@@ -179,6 +221,15 @@ Data source: GitHub Trending (热门仓库列表)
 - One scene per repo. EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
 - Connect repos with a short narrative thread between scenes if there's a shared theme (e.g. "今天 AI Agent 持续火热"), but each project scene stands on its own.
 - Tone: pragmatic, factual, no hype. The audience is developers.
+${githubProjectIntroRule}`,
+
+    "github-weekly": `
+Data source: GitHub Weekly Trending (本周热门仓库列表, since=weekly)
+- The input is a list of this week's top-gaining repositories. Treat it as a weekly roundup — emphasize momentum and what sustained a whole week, not one-day spikes.
+- Select EXACTLY the 6 repos with the highest weekly star gain (github.repo.todayStars, mirrored by the 'Week: +N' line), in descending order. Produce one scene per repo in that order so the video follows the same ranking shown on the cover. If the input has fewer than 6 repos, use all of them.
+- One scene per repo. EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
+- Connect repos with a short narrative thread between scenes if there's a shared theme (e.g. "这周 AI Agent 项目集中爆发"), but each project scene stands on its own.
+- Tone: pragmatic, factual, no hype. The audience is developers.
 ${githubProjectIntroRule}`,
 
     "github-repo": `

+ 8 - 5
packages/shared/src/types/document.ts

@@ -85,16 +85,19 @@ export const GithubTrendingImagesSchema = z.object({
 });
 export type GithubTrendingImages = z.infer<typeof GithubTrendingImagesSchema>;
 
-export const GithubTrendingExtensionSchema = GithubSceneDataSchema.extend({
-  type: z.literal("github-trending"),
+/** github-trending / github-weekly share this extension shape (repo +
+ *  highlights/intro/review + named images); only the discriminator differs,
+ *  so both boards read the same fields. */
+export const GithubBoardExtensionSchema = GithubSceneDataSchema.extend({
+  type: z.enum(["github-trending", "github-weekly"]),
   /** Named images the template renders (object, not array). Renderer resolves
    *  each into a filename; the template reads them by name. */
   images: GithubTrendingImagesSchema.optional(),
 });
-export type GithubTrendingExtension = z.infer<typeof GithubTrendingExtensionSchema>;
+export type GithubBoardExtension = z.infer<typeof GithubBoardExtensionSchema>;
 
 export const SegmentExtensionSchema = z.discriminatedUnion("type", [
-  GithubTrendingExtensionSchema,
+  GithubBoardExtensionSchema,
 ]);
 export type SegmentExtension = z.infer<typeof SegmentExtensionSchema>;
 
@@ -167,7 +170,7 @@ export type VideoDocumentMeta = z.infer<typeof VideoDocumentMetaSchema>;
 export const VideoDocumentSchema = z.object({
   version: z.literal(VIDEO_DOCUMENT_VERSION),
   type: z.literal(VIDEO_DOCUMENT_TYPE),
-  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending"]),
+  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"]),
   config: VideoDocumentConfigSchema.optional(),
   meta: VideoDocumentMetaSchema.optional(),
   data: z.array(VideoSegmentSchema),

+ 2 - 2
packages/shared/src/types/index.ts

@@ -64,7 +64,7 @@ export {
   MenuEntrySchema,
   DocumentImageSchema,
   GithubTrendingImagesSchema,
-  GithubTrendingExtensionSchema,
+  GithubBoardExtensionSchema,
   SegmentExtensionSchema,
   SegmentKindSchema,
   VideoSegmentSchema,
@@ -78,7 +78,7 @@ export type {
   MenuEntry,
   DocumentImage,
   GithubTrendingImages,
-  GithubTrendingExtension,
+  GithubBoardExtension,
   SegmentExtension,
   SegmentKind,
   VideoSegment,

+ 2 - 2
packages/shared/src/types/pipeline.ts

@@ -3,7 +3,7 @@ import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSch
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
-  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending"]),
+  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"]),
   platforms: z.array(z.enum([
     "bilibili",
     "douyin-long",
@@ -90,7 +90,7 @@ export const ComposedProjectSchema = z.object({
   durationInFrames: z.number(),
   audioPath: z.string(),
   scenes: z.array(ComposedSceneSchema),
-  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending"]),
+  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"]),
   platform: z.string(),
   title: z.string().optional(),
   subtitle: z.string().optional(),

+ 3 - 2
packages/shared/src/types/render.ts

@@ -23,9 +23,10 @@ export interface RenderGithubImages {
 }
 
 /** Render-time (resolved) per-template extension. Mirrors SegmentExtension but
- *  with images resolved to filenames. Built by the renderer; templates read this. */
+ *  with images resolved to filenames. Built by the renderer; templates read this.
+ *  github-trending and github-weekly share the shape (only the type tag differs). */
 export type RenderSegmentExtension = {
-  type: "github-trending";
+  type: "github-trending" | "github-weekly";
   repo: RepoMeta;
   highlights: string;
   intro: string;

+ 34 - 0
packages/shared/src/utils/date.ts

@@ -53,3 +53,37 @@ export function isoDateString(
   const p = dateParts(date, timeZone, PARTS_DATE_PADDED);
   return `${part(p, "year")}-${part(p, "month")}-${part(p, "day")}`;
 }
+
+/**
+ * The calendar week containing `date`, formatted as a compact zh-CN range
+ * "YYYY.MM.DD - MM.DD" (Monday → Sunday, both ends in the given timezone).
+ * Used as the github-weekly cover subtitle so the board reads as "this week"
+ * rather than a single day. When start/end share a year only the end's
+ * month.day repeats (e.g. "2026.08.10 - 08.16"); a year-crossing week spells
+ * both out (e.g. "2026.12.28 - 2027.01.03").
+ */
+export function formatWeekRange(
+  date: Date = new Date(),
+  timeZone: string = DEFAULT_TIMEZONE,
+): string {
+  const fmt = (d: Date) => {
+    const p = dateParts(d, timeZone, PARTS_DATE_PADDED);
+    const y = part(p, "year");
+    const md = `${part(p, "month")}.${part(p, "day")}`;
+    return { y, md };
+  };
+
+  // Weekday index with Monday=0: JS getDay() is Sunday=0..Saturday=6.
+  const dowMon0 = (Number(part(dateParts(date, timeZone, { year: "numeric", month: "numeric", day: "numeric" }), "weekday")) + 6) % 7;
+
+  // Step from `date` to the week's Monday/Sunday by shifting whole days. A day
+  // offset of ±k at fixed wall-clock times can cross DST boundaries unevenly,
+  // but Asia/Shanghai (the pinned default) has no DST; custom timezones that do
+  // may drift an hour, which dateParts absorbs by formatting in that timezone.
+  const start = new Date(date.getTime() - dowMon0 * 24 * 3600 * 1000);
+  const end = new Date(date.getTime() + (6 - dowMon0) * 24 * 3600 * 1000);
+
+  const s = fmt(start);
+  const e = fmt(end);
+  return s.y === e.y ? `${s.y}.${s.md} - ${e.md}` : `${s.y}.${s.md} - ${e.y}.${e.md}`;
+}

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

@@ -1,5 +1,5 @@
 export { stripUnsupportedGlyphs, clipToLength } from "./sanitize-text.js";
 export { formatCount } from "./format.js";
-export { isoDateString, formatChineseDate, DEFAULT_TIMEZONE } from "./date.js";
+export { isoDateString, formatChineseDate, formatWeekRange, DEFAULT_TIMEZONE } from "./date.js";
 export { normalizeCountsForTTS } from "./narration.js";
 export { audioFilenameFor } from "./audio-filename.js";

+ 149 - 15
packages/templates/src/Root.tsx

@@ -6,6 +6,7 @@ import KnowledgeScene from "./knowledge/index";
 import OpinionScene from "./opinion/index";
 import MarketingScene from "./marketing/index";
 import GithubTrendingScene, { ColorDot } from "./github-trending/index";
+import GithubWeeklyScene from "./github-weekly/index";
 import { THEMES } from "./base/theme/colors";
 
 // Re-export the render-time contract so legacy imports (`import { RemotionScene
@@ -14,10 +15,13 @@ import { THEMES } from "./base/theme/colors";
 export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
 import type { RenderSegmentExtension } from "@pipeline/shared";
 
-/** Narrow a scene's per-template extension to the github-trending variant, or
- *  undefined. Template-specific data lives in `extension`, not on the scene root. */
+/** Narrow a scene's per-template extension to a github board variant (trending
+ *  or weekly — same shape), or undefined. Template-specific data lives in
+ *  `extension`, not on the scene root. */
 const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
-  s.extension?.type === "github-trending" ? s.extension : undefined;
+  s.extension?.type === "github-trending" || s.extension?.type === "github-weekly"
+    ? s.extension
+    : undefined;
 
 /** Default visual length (seconds) for a scene with no audio (e.g. a silent
  *  cover). Visual duration is the TEMPLATE's call — it may exceed the audio to
@@ -42,6 +46,7 @@ const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
   opinion: OpinionScene,
   marketing: MarketingScene,
   "github-trending": GithubTrendingScene,
+  "github-weekly": GithubWeeklyScene,
 };
 
 const RATIO_ID: Record<AspectRatio, string> = {
@@ -54,13 +59,13 @@ const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
   "9:16": { width: 1080, height: 1920 },
 };
 
-const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
+const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"];
 
 const TemplateComposition: React.FC<RenderProps> = (props) => {
   const { fps } = useVideoConfig();
   const SceneComponent = SCENE_MAP[props.template];
-  // github-trending: the cover aggregates the repos' languages into tag
-  // chips. Content scenes carry repo data in extension.
+  // github boards: the cover aggregates the repos' languages into tag chips.
+  // Content scenes carry repo data in extension.
   const coverRepos = props.scenes.filter(
     (s) => s.kind === "content" && ghExt(s)
   );
@@ -140,7 +145,9 @@ const TemplateComposition: React.FC<RenderProps> = (props) => {
         );
       })}
       <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
-      {props.template === "github-trending" && <ChapterToc layout={layout} />}
+      {(props.template === "github-trending" || props.template === "github-weekly") && (
+        <ChapterToc layout={layout} template={props.template} />
+      )}
     </AbsoluteFill>
   );
 };
@@ -200,6 +207,123 @@ const CoverScene: React.FC<{
   const { width, height } = useVideoConfig();
   const isPortrait = height > width;
 
+  // github-weekly cover: the LIGHT magazine-weekly masthead — paper
+  // background, editorial rules top and bottom, the masthead title + week
+  // range + language tags + trend line typeset like a journal front page. No
+  // background image (the paper IS the identity); the light gradient stands
+  // alone so no asset dependency exists for this board.
+  if (template === "github-weekly") {
+    const theme = THEMES[template];
+    const primary = theme.primary;
+    // Aggregate languages across the week's repos → the cover's "main tags".
+    // Count-weighted, top repo's languageColor wins per language.
+    const langStats = new Map<string, { count: number; color?: string }>();
+    for (const s of coverRepos ?? []) {
+      const repo = ghExt(s)!.repo;
+      if (!repo.language) continue;
+      const stats = langStats.get(repo.language) ?? { count: 0, color: repo.languageColor };
+      stats.count += 1;
+      langStats.set(repo.language, stats);
+    }
+    const topLangs = [...langStats.entries()]
+      .sort((a, b) => b[1].count - a[1].count)
+      .slice(0, 6);
+
+    return (
+      <AbsoluteFill style={{
+        background: `linear-gradient(150deg, ${theme.bg} 0%, ${theme.bgLight} 60%, #efe5d1 100%)`,
+      }}>
+        {/* Faint paper ruling — a journal's faint column grid */}
+        <div style={{
+          position: "absolute", inset: 0,
+          backgroundImage: `
+            linear-gradient(rgba(30,41,59,0.035) 1px, transparent 1px),
+            linear-gradient(90deg, rgba(30,41,59,0.035) 1px, transparent 1px)
+          `,
+          backgroundSize: "96px 96px",
+        }} />
+        {/* Editorial double rule, top and bottom */}
+        <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 8, background: primary }} />
+        <div style={{ position: "absolute", top: 14, left: 0, width: "100%", height: 1, background: `${primary}66` }} />
+        <div style={{ position: "absolute", bottom: 0, left: 0, width: "100%", height: 6, background: `${theme.accent}` }} />
+        <div style={{ position: "absolute", bottom: 12, left: 0, width: "100%", height: 1, background: `${theme.accent}55` }} />
+
+        <div style={{
+          position: "absolute", inset: 0, display: "flex", flexDirection: "column",
+          alignItems: "center",
+          justifyContent: "center",
+          padding: isPortrait ? "72px 64px 150px" : "58px 80px 130px",
+        }}>
+          {/* Issue kicker above the masthead — journal convention */}
+          <div style={{
+            fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: primary,
+            fontFamily: "Noto Sans SC", letterSpacing: "0.42em",
+            textTransform: "uppercase", marginBottom: 18,
+          }}>
+            Weekly Issue
+          </div>
+          {/* Masthead title */}
+          <div style={{
+            fontSize: isPortrait ? 112 : 104, fontWeight: 800, color: "#1e293b",
+            fontFamily: "Noto Sans SC", lineHeight: 1.1, letterSpacing: "0.02em",
+            borderBottom: `3px solid ${primary}`,
+            paddingBottom: 20,
+          }}>
+            {title}
+          </div>
+          {/* Week range */}
+          {subtitle && (
+            <div style={{
+              fontSize: isPortrait ? 38 : 34, fontWeight: 600, color: "#57534e",
+              fontFamily: "Noto Sans SC", letterSpacing: "0.08em",
+              marginTop: 26,
+            }}>
+              {subtitle}
+            </div>
+          )}
+          {/* Main tags — languages aggregated across the week's repos */}
+          {topLangs.length > 0 && (
+            <div style={{
+              display: "flex", flexWrap: "wrap",
+              alignItems: "center", justifyContent: "center",
+              gap: 14, marginTop: 30,
+            }}>
+              {topLangs.map(([lang, stats]) => (
+                <span key={lang} style={{
+                  display: "inline-flex", alignItems: "center", gap: 9,
+                  fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#334155",
+                  fontFamily: "Noto Sans SC",
+                  padding: "10px 24px", borderRadius: 999,
+                  background: "rgba(255,255,255,0.75)",
+                  border: `1px solid ${(stats.color || primary)}55`,
+                }}>
+                  <ColorDot color={stats.color || primary} size={isPortrait ? 14 : 12} />
+                  {lang}
+                </span>
+              ))}
+            </div>
+          )}
+          {/* Trend summary — set like a journal dek */}
+          {trendSummary && (
+            <>
+              <div style={{
+                width: "52%", height: 1, marginTop: 34,
+                background: "rgba(30,41,59,0.18)",
+              }} />
+              <div style={{
+                fontSize: isPortrait ? 34 : 29, fontWeight: 500, color: "#44403c",
+                fontFamily: "Noto Sans SC", lineHeight: 1.5,
+                maxWidth: "90%", marginTop: 18,
+              }}>
+                {trendSummary}
+              </div>
+            </>
+          )}
+        </div>
+      </AbsoluteFill>
+    );
+  }
+
   // github-trending cover: the renderer-provided background image (default.png
   // fallback — no template-specific bg exists yet) under a dark scrim, with a
   // DARK title card fully centered on top — no repo list. Shows only the
@@ -456,16 +580,20 @@ const GlobalProgressBar: React.FC<{
 
 const ChapterToc: React.FC<{
   layout: { scene: RenderScene; startFrame: number; endFrame: number; durationFrames: number }[];
-}> = ({ layout }) => {
-  // github-trending only: a chapter table of contents pinned just above the
+  template: TemplateType;
+}> = ({ layout, template }) => {
+  // github boards only: a chapter table of contents pinned just above the
   // global progress bar. Lists every repo's short name; the chapter whose
   // [startFrame, endFrame) contains the current frame is highlighted. A faint
-  // dark gradient strip behind the row keeps labels legible over light
-  // backgrounds (e.g. the cover) and visually groups the TOC with the bar.
+  // gradient strip behind the row keeps labels legible — dark strip + white
+  // text for github-trending, light strip + slate text for github-weekly's
+  // paper theme.
   const frame = useCurrentFrame();
   const { width, height } = useVideoConfig();
   const isPortrait = height > width;
-  const accent = THEMES["github-trending"].primary;
+  const theme = THEMES[template];
+  const onLight = template === "github-weekly";
+  const accent = theme.primary;
 
   const chapters = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
   const active = chapters.findIndex((c) => frame >= c.startFrame && frame < c.endFrame);
@@ -479,7 +607,9 @@ const ChapterToc: React.FC<{
           left: 0,
           width: "100%",
           height: 72,
-          background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
+          background: onLight
+            ? "linear-gradient(to top, rgba(250,247,242,0.9), transparent)"
+            : "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
           pointerEvents: "none",
         }}
       />
@@ -507,7 +637,7 @@ const ChapterToc: React.FC<{
               {i > 0 && (
                 <span
                   style={{
-                    color: "rgba(255,255,255,0.3)",
+                    color: onLight ? "rgba(30,41,59,0.35)" : "rgba(255,255,255,0.3)",
                     fontSize: isPortrait ? 22 : 18,
                     flexShrink: 0,
                   }}
@@ -521,7 +651,11 @@ const ChapterToc: React.FC<{
                   minWidth: 0,
                   fontSize: isPortrait ? 26 : 22,
                   fontWeight: isActive ? 700 : 500,
-                  color: isActive ? accent : "rgba(255,255,255,0.6)",
+                  color: isActive
+                    ? accent
+                    : onLight
+                      ? "rgba(51,65,85,0.62)"
+                      : "rgba(255,255,255,0.6)",
                   whiteSpace: "nowrap",
                   overflow: "hidden",
                   textOverflow: "ellipsis",

+ 4 - 1
packages/templates/src/base/components/watermark.tsx

@@ -4,11 +4,14 @@ import { useCurrentFrame, interpolate, Easing } from "remotion";
 interface WatermarkProps {
   text?: string;
   opacity?: number;
+  /** Light backgrounds (e.g. github-weekly's paper theme) — render dark text. */
+  light?: boolean;
 }
 
 export const Watermark: React.FC<WatermarkProps> = ({
   text = "Pipeline",
   opacity = 0.15,
+  light = false,
 }) => {
   const frame = useCurrentFrame();
   const fadeIn = interpolate(frame, [0, 30], [0, opacity], {
@@ -23,7 +26,7 @@ export const Watermark: React.FC<WatermarkProps> = ({
         right: 32,
         fontSize: 18,
         fontWeight: 600,
-        color: "white",
+        color: light ? "#1e293b" : "white",
         opacity: fadeIn,
         fontFamily: "Noto Sans SC",
         letterSpacing: 2,

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

@@ -49,6 +49,16 @@ export const THEMES = {
     textMuted: "#94a3b8",
     gradient: ["#0f172a", "#1e3a8a"],
   },
+  "github-weekly": {
+    primary: "#d97706",
+    primaryLight: "#f59e0b",
+    accent: "#1e3a5f",
+    bg: "#faf7f2",
+    bgLight: "#f3ede2",
+    text: "#1e293b",
+    textMuted: "#78716c",
+    gradient: ["#faf7f2", "#f0e6d4"],
+  },
 } as const;
 
 export type ThemeName = keyof typeof THEMES;
@@ -72,3 +82,22 @@ export const GITHUB_TRENDING_PALETTE = [
 ] as const;
 
 export type GithubTrendingPaletteEntry = (typeof GITHUB_TRENDING_PALETTE)[number];
+
+/**
+ * Per-scene rotating palette for the github-weekly template — the LIGHT
+ * magazine-weekly counterpart of GITHUB_TRENDING_PALETTE. Paper-toned two-stop
+ * gradients with a deep editorial accent per entry; all light enough that
+ * dark slate text reads cleanly on top.
+ */
+export const GITHUB_WEEKLY_PALETTE = [
+  { from: "#faf7f2", to: "#efe6d3", accent: "#b45309" }, // parchment / amber
+  { from: "#f5f6f8", to: "#e7ebf2", accent: "#1e40af" }, // cool paper / navy
+  { from: "#f7f5f2", to: "#eae7df", accent: "#57534e" }, // warm grey / stone
+  { from: "#f4f7f5", to: "#e5efe9", accent: "#166534" }, // sage / forest
+  { from: "#faf5f4", to: "#f0e5e3", accent: "#9f1239" }, // blush / rosewood
+  { from: "#f6f5f7", to: "#eae7f0", accent: "#6d28d9" }, // lilac / violet
+  { from: "#f5f7f8", to: "#e6eef1", accent: "#0e7490" }, // mist / petrol
+  { from: "#f8f6f1", to: "#eee8da", accent: "#a16207" }, // vellum / bronze
+] as const;
+
+export type GithubWeeklyPaletteEntry = (typeof GITHUB_WEEKLY_PALETTE)[number];

+ 464 - 0
packages/templates/src/github-weekly/index.tsx

@@ -0,0 +1,464 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  Img,
+  staticFile,
+  interpolate,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import type { RenderSegmentExtension } from "@pipeline/shared";
+import { GITHUB_WEEKLY_PALETTE } from "../base/theme/colors";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { Watermark } from "../base/components/watermark";
+import { formatCount } from "@pipeline/shared";
+
+/**
+ * github-weekly content scene — the LIGHT magazine-weekly counterpart of the
+ * github-trending scene. Paper background, dark slate text, editorial section
+ * cards (white cards on paper, the inverse of the daily board's dark-on-dark),
+ * and the same named images (social preview + star history) from the shared
+ * github board extension.
+ */
+interface GithubWeeklySceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+  channelName?: string;
+}
+
+const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
+  scene,
+  sceneIndex,
+  channelName,
+}) => {
+  const frame = useCurrentFrame();
+  const { width, height, durationInFrames } = useVideoConfig();
+  const isPortrait = height > width;
+  const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
+  const github: RenderSegmentExtension | undefined =
+    scene.extension?.type === "github-trending" || scene.extension?.type === "github-weekly"
+      ? scene.extension
+      : undefined;
+
+  const sceneDur = durationInFrames;
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, sceneDur - 8, sceneDur],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  // Named images from the shared board extension (resolved to filenames by the
+  // renderer). Object, not positional array — socialPreview / starHistory.
+  const socialPreview = github?.images?.socialPreview;
+  const starHistory = github?.images?.starHistory;
+
+  const pad = isPortrait ? 48 : 80;
+  const footerReserved = isPortrait ? 500 : 140;
+
+  // Landscape geometry (portrait uses flex layout below — no headerHeight).
+  const landscapeHeaderHeight = 220;
+  const landscapeDividerY = pad + landscapeHeaderHeight;
+  const landscapeContentTop = landscapeDividerY + 24;
+  const landscapeContentHeight = height - footerReserved - landscapeContentTop;
+  const landscapeContentWidth = width - pad * 2;
+
+  const repo = github?.repo;
+  const language = repo?.language || "";
+  const languageColor = repo?.languageColor || palette.accent;
+  // Under the weekly board, todayStars carries the WEEKLY gain.
+  const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
+  const weekGainLabel =
+    repo?.todayStars != null ? `本周 +${formatCount(repo.todayStars)}` : "";
+  const license = repo?.license || "";
+  const fullName = repo?.fullName || scene.title || "";
+
+  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: "#1e293b", fontFamily: "Noto Sans SC", textAlign: "center" }}>
+            {scene.title || scene.captionOrigin}
+          </div>
+        </div>
+        {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
+        <Watermark text={channelName} light />
+      </AbsoluteFill>
+    );
+  }
+
+  const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
+    <div>
+      <div style={{
+        fontSize,
+        fontWeight: 800,
+        color: "#1e293b",
+        fontFamily: "Noto Sans SC",
+        lineHeight: 1.15,
+        letterSpacing: "-0.01em",
+      }}>
+        {fullName}
+      </div>
+      <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
+        {language && (
+          <Tag accent={palette.accent} size={tagSize}>
+            <ColorDot color={languageColor} size={dotSize} />
+            {language}
+          </Tag>
+        )}
+        {weekGainLabel && (
+          <Tag accent={palette.accent} size={tagSize} emphasized>
+            {weekGainLabel}
+          </Tag>
+        )}
+        {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
+        {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
+      </div>
+    </div>
+  );
+
+  return (
+    <AbsoluteFill style={{
+      opacity: fadeOpacity,
+      background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
+    }}>
+      {/* Subtle paper ruling (the light counterpart of the daily board's grid) */}
+      <div style={{
+        position: "absolute", inset: 0,
+        backgroundImage: `
+          linear-gradient(rgba(30,41,59,0.03) 1px, transparent 1px),
+          linear-gradient(90deg, rgba(30,41,59,0.03) 1px, transparent 1px)
+        `,
+        backgroundSize: "80px 80px",
+      }} />
+
+      {/* Top accent rule — a double editorial rule, magazine style */}
+      <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
+      <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
+
+      {isPortrait ? (
+        // Portrait: flex column. Header takes natural height, divider sits
+        // right below it, content fills the remainder. paddingBottom reserves
+        // space for the elevated subtitle (bottom: 380).
+        <div style={{
+          position: "relative",
+          display: "flex",
+          flexDirection: "column",
+          width: "100%",
+          height: "100%",
+          boxSizing: "border-box",
+          padding: `${pad}px ${pad}px ${footerReserved}px`,
+        }}>
+          {renderHeader(92, "large", 16)}
+          <div style={{
+            height: 1,
+            background: "rgba(30,41,59,0.18)",
+            marginTop: 24,
+          }} />
+          <PortraitContent
+            palette={palette}
+            socialPreview={socialPreview}
+            starHistory={starHistory}
+            github={github}
+          />
+        </div>
+      ) : (
+        // Landscape: absolute positioning (single-line 80px header).
+        <>
+          <div style={{
+            position: "absolute",
+            top: pad,
+            left: pad,
+            right: pad,
+          }}>
+            {renderHeader(80, "default", 14)}
+          </div>
+
+          <div style={{
+            position: "absolute",
+            top: landscapeDividerY,
+            left: pad,
+            right: pad,
+            height: 1,
+            background: "rgba(30,41,59,0.18)",
+          }} />
+
+          <LandscapeContent
+            contentTop={landscapeContentTop}
+            contentHeight={landscapeContentHeight}
+            pad={pad}
+            contentWidth={landscapeContentWidth}
+            palette={palette}
+            socialPreview={socialPreview}
+            starHistory={starHistory}
+            github={github}
+          />
+        </>
+      )}
+
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar
+          caption={scene.caption}
+          style={isPortrait ? { bottom: 380 } : undefined}
+          fontSize={isPortrait ? 56 : undefined}
+        />
+      )}
+      <Watermark text={channelName} light />
+    </AbsoluteFill>
+  );
+};
+
+// --- Sub-components (light counterparts of the github-trending ones) ---
+
+export const Tag: React.FC<{
+  accent: string;
+  children: React.ReactNode;
+  size?: "default" | "large";
+  emphasized?: boolean;
+}> = ({ accent, children, size = "default", emphasized }) => (
+  <div style={{
+    display: "inline-flex",
+    alignItems: "center",
+    gap: 10,
+    padding: "12px 24px",
+    borderRadius: 999,
+    background: emphasized ? accent : "rgba(255,255,255,0.75)",
+    border: `1px solid ${emphasized ? accent : `${accent}44`}`,
+    color: emphasized ? "#ffffff" : "#334155",
+    fontFamily: "Noto Sans SC",
+    fontSize: size === "large" ? 32 : 28,
+    fontWeight: emphasized ? 700 : 500,
+    backdropFilter: "blur(4px)",
+  }}>
+    {children}
+  </div>
+);
+
+export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
+  <span style={{
+    display: "inline-block",
+    width: size,
+    height: size,
+    borderRadius: "50%",
+    background: color || "#999",
+  }} />
+);
+
+const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
+  <div style={{
+    flex,
+    width: flex ? undefined : "100%",
+    height: "auto",
+    background: "#ffffff",
+    border: "1px solid rgba(30,41,59,0.12)",
+    borderRadius: 12,
+    overflow: "hidden",
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+    boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
+  }}>
+    {children}
+  </div>
+);
+
+const Section: React.FC<{
+  title: string;
+  accent: string;
+  body: string;
+  flex?: number;
+  size?: "default" | "large";
+}> = ({ title, accent, body, flex, size = "default" }) => {
+  const isLarge = size === "large";
+  // Strip trailing punctuation that would look awkward if rendered at the
+  // end of the clamped text (LLM occasionally leaves a hanging 。,,;).
+  const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
+  return (
+    <div style={{
+      flex: flex ?? 1,
+      background: "rgba(255,255,255,0.82)",
+      border: "1px solid rgba(30,41,59,0.10)",
+      borderLeft: `${isLarge ? 6 : 5}px solid ${accent}`,
+      borderRadius: 10,
+      padding: isLarge ? "24px 32px" : "22px 28px",
+      display: "flex",
+      flexDirection: "column",
+      gap: 12,
+      minHeight: 0,
+    }}>
+      <div style={{
+        display: "flex",
+        alignItems: "center",
+        gap: 12,
+      }}>
+        {/* No color bar (it moved to the card's left border) — editorial
+            small-caps style label with wide tracking instead. */}
+        <span style={{
+          fontSize: isLarge ? 34 : 28,
+          fontWeight: 700,
+          color: accent,
+          fontFamily: "Noto Sans SC",
+          letterSpacing: "0.14em",
+        }}>
+          {title}
+        </span>
+      </div>
+      <div style={{
+        // Size to content (capped at N lines by -webkit-line-clamp), not by
+        // flex distribution — mirrors the github-trending Section rationale.
+        flex: "0 0 auto",
+        fontSize: isLarge ? 60 : 32,
+        lineHeight: isLarge ? 1.35 : 1.4,
+        color: "#334155",
+        fontFamily: "Noto Sans SC",
+        fontWeight: 400,
+        display: "-webkit-box",
+        WebkitLineClamp: isLarge ? 3 : 5,
+        WebkitBoxOrient: "vertical",
+        overflow: "hidden",
+        overflowWrap: "break-word",
+      }}>
+        {cleanBody}
+      </div>
+    </div>
+  );
+};
+
+interface PortraitContentProps {
+  palette: { from: string; to: string; accent: string };
+  socialPreview?: { filename: string };
+  starHistory?: { filename: string };
+  github: RenderSegmentExtension;
+}
+
+// PortraitContent uses flex:1 to fill remaining height after the header
+// (which takes its natural height in the parent flex column).
+const PortraitContent: React.FC<PortraitContentProps> = ({
+  palette, socialPreview, starHistory, github,
+}) => {
+  const hasAnyImage = !!(socialPreview || starHistory);
+  return (
+    <div style={{
+      flex: 1,
+      display: "flex",
+      flexDirection: "column",
+      gap: 28,
+      paddingTop: 24,
+      minHeight: 0,
+    }}>
+      {/* Top row: images side-by-side, each flex:1 (same width) with natural
+          height. Row sizes to tallest child; shorter child top-aligned. */}
+      {hasAnyImage && (
+        <div style={{
+          display: "flex",
+          gap: 20,
+          alignItems: "flex-start",
+        }}>
+          {socialPreview && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            </ImageFrame>
+          )}
+          {starHistory && (
+            <ImageFrame flex={1}>
+              <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            </ImageFrame>
+          )}
+        </div>
+      )}
+
+      {/* Bottom: 2 sections stacked (full height when no images) */}
+      <div style={{
+        flex: 1,
+        display: "flex",
+        flexDirection: "column",
+        gap: 28,
+      }}>
+        <Section title="项目亮点" accent={palette.accent} body={github.highlights} size="large" />
+        <Section title="建议" accent={palette.accent} body={github.review} size="large" />
+      </div>
+    </div>
+  );
+};
+
+interface LandscapeContentProps {
+  contentTop: number;
+  contentHeight: number;
+  pad: number;
+  contentWidth: number;
+  palette: { from: string; to: string; accent: string };
+  socialPreview?: { filename: string };
+  starHistory?: { filename: string };
+  github: RenderSegmentExtension;
+}
+
+const LandscapeContent: React.FC<LandscapeContentProps> = ({
+  contentTop, contentHeight, pad, contentWidth,
+  palette, socialPreview, starHistory, github,
+}) => {
+  const hasAnyImage = !!(socialPreview || starHistory);
+  // Left column: cap width so both images stacked fit within contentHeight.
+  // Assumes social preview ~2:1 and star history ~1.5:1 → combined height 7/6.
+  const leftColWidth = hasAnyImage
+    ? Math.min(
+        Math.round(contentWidth * 0.4),
+        Math.round((contentHeight - 20) / (7 / 6)),
+      )
+    : 0;
+  const colGap = 40;
+
+  return (
+    <div style={{
+      position: "absolute",
+      top: contentTop,
+      left: pad,
+      width: contentWidth,
+      height: contentHeight,
+      display: "flex",
+      gap: colGap,
+    }}>
+      {hasAnyImage && (
+        <div style={{
+          width: leftColWidth,
+          height: contentHeight,
+          display: "flex",
+          flexDirection: "column",
+          gap: 20,
+          overflow: "hidden",
+        }}>
+          {socialPreview && (
+            <ImageFrame>
+              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            </ImageFrame>
+          )}
+          {starHistory && (
+            <ImageFrame>
+              <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            </ImageFrame>
+          )}
+        </div>
+      )}
+
+      <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={3} />
+        <Section title="建议" accent={palette.accent} body={github.review} />
+      </div>
+    </div>
+  );
+};
+
+export default GithubWeeklyScene;

+ 32 - 20
packages/text/src/assemble.ts

@@ -1,6 +1,7 @@
 import {
   normalizeCountsForTTS,
   formatChineseDate,
+  formatWeekRange,
   type VideoInput,
   type VideoDocument,
   type VideoSegment,
@@ -13,6 +14,11 @@ import {
 } from "@pipeline/shared";
 import { getTimezone } from "@pipeline/shared/node";
 
+/** github-trending / github-weekly share the whole board flow (fixed masthead
+ *  cover, repo-extension scenes, top6 by star gain) — only the masthead text
+ *  and time framing differ. */
+const isGithubBoard = (t: TemplateType) => t === "github-trending" || t === "github-weekly";
+
 /**
  * Assembly layer — turns a validated VideoInput (+ typed repo metadata from the
  * data source) into the canonical VideoDocument. Ports the scene-building
@@ -34,14 +40,17 @@ export function assembleDocument(
   template: TemplateType,
   repos: RepoMeta[]
 ): AssembledDocument {
-  // github-trending: the cover is a fixed masthead — title is the channel name,
-  // subtitle is today's date in zh-CN. Override whatever the LLM produced so the
-  // cover stays deterministic.
-  if (template === "github-trending") {
+  // github boards: the cover is a fixed masthead — title is the board name,
+  // subtitle is the board's date range (daily: today's date; weekly: this
+  // week's Mon-Sun range). Override whatever the LLM produced so the cover
+  // stays deterministic.
+  if (isGithubBoard(template)) {
     videoInput = {
       ...videoInput,
-      title: "GitHub 每日热榜",
-      subtitle: formatChineseDate(new Date(), getTimezone()),
+      title: template === "github-weekly" ? "GitHub 周榜" : "GitHub 每日热榜",
+      subtitle: template === "github-weekly"
+        ? formatWeekRange(new Date(), getTimezone())
+        : formatChineseDate(new Date(), getTimezone()),
     };
   }
 
@@ -67,7 +76,8 @@ export function assembleDocument(
     const typed = repoByFullName.get(key) ?? s.github.repo;
     return {
       extension: {
-        type: "github-trending",
+        // github-trending | github-weekly — same shape (isGithubBoard guard above)
+        type: template as "github-trending" | "github-weekly",
         repo: typed,
         highlights: s.github.highlights,
         intro: s.github.intro,
@@ -89,10 +99,10 @@ export function assembleDocument(
   // --- Cover ---
   const coverInput = videoInput.cover;
   const firstScene = videoInput.scenes[0];
-  // github-trending cover is a clean title screen — no inherited keyframes or
-  // image. Other templates inherit from the first scene so the cover reflects
-  // the video's actual topic.
-  const inheritFromFirstScene = template !== "github-trending";
+  // github boards: the cover is a clean title screen — no inherited keyframes
+  // or image. Other templates inherit from the first scene so the cover
+  // reflects the video's actual topic.
+  const inheritFromFirstScene = !isGithubBoard(template);
   const coverKeyframes = inheritFromFirstScene
     ? (coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3))
     : (coverInput?.keyframes ?? []);
@@ -107,16 +117,17 @@ export function assembleDocument(
       ]
     : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
 
-  // Cover opens: greeting → today's trend (spoken from the LLM-produced
+  // Cover opens: greeting → the period's trend (spoken from the LLM-produced
   // trendSummary) → transition into the repo rundown. No date/count (shown
   // visually). Falls back to a bare greeting+transition when trendSummary is
-  // absent. Non-github-trending covers stay silent (1s).
+  // absent. Non-github-board covers stay silent (1s).
   const trend = (videoInput.trendSummary ?? "")
     .trim()
     .replace(/[。!?.!?\s]+$/u, "");
-  const coverNarration = template === "github-trending"
+  const periodWord = template === "github-weekly" ? "本周" : "今天";
+  const coverNarration = isGithubBoard(template)
     ? trend
-      ? `大家好,今天${trend}。下面进入项目详解。`
+      ? `大家好,${periodWord}${trend}。下面进入项目详解。`
       : "大家好,下面进入项目详解。"
     : "";
 
@@ -133,10 +144,11 @@ export function assembleDocument(
   });
 
   // --- Content ---
-  // github-trending covers the TOP 6 repos by today's star gain (matching the
-  // cover preview), in descending-gain order so playback follows the cover
-  // ranking. Other templates keep all input scenes.
-  const contentInputs = template === "github-trending"
+  // github boards cover the TOP 6 repos by the period's star gain (matching
+  // the cover preview), in descending-gain order so playback follows the cover
+  // ranking. RepoMeta.todayStars carries the period gain (daily or weekly —
+  // the collector decides the period). Other templates keep all input scenes.
+  const contentInputs = isGithubBoard(template)
     ? videoInput.scenes
         .filter((s) => s.github)
         .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
@@ -189,7 +201,7 @@ export function assembleDocument(
       title: videoInput.title,
       subtitle: videoInput.subtitle ?? videoInput.scenes[0]?.title,
       summary: videoInput.summary || undefined,
-      trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
+      trendSummary: isGithubBoard(template) ? videoInput.trendSummary : undefined,
     },
     data: segments,
   };

+ 7 - 4
packages/text/src/postprocess.ts

@@ -19,9 +19,12 @@ export const JSON_TRUNCATION_NUDGE =
  * validation. Clips at a sentence/clause boundary when possible so the result
  * still reads naturally.
  *
- * When `template === "github-trending"`, also strips leading greetings from
- * every scene's narration — the cover scene already opens with a greeting.
+ * For the github boards (github-trending / github-weekly), also strips leading
+ * greetings from every scene's narration — the cover scene already opens with
+ * a greeting.
  */
+const isGithubBoardTemplate = (t: string) => t === "github-trending" || t === "github-weekly";
+
 export function applyLengthLimits(input: unknown, template: string): unknown {
   if (!input || typeof input !== "object") return input;
   const root = (input as any).scenes && Array.isArray((input as any).scenes)
@@ -32,7 +35,7 @@ export function applyLengthLimits(input: unknown, template: string): unknown {
   (root as any).scenes = (root as any).scenes.map((scene: any) => {
     if (!scene || typeof scene !== "object") return scene;
     const next: any = { ...scene };
-    const narration = template === "github-trending"
+    const narration = isGithubBoardTemplate(template)
       ? stripLeadingGreeting(scene.narration)
       : scene.narration;
     next.narration = clipToLength(narration, 200);
@@ -47,7 +50,7 @@ export function applyLengthLimits(input: unknown, template: string): unknown {
     return next;
   });
 
-  if (template === "github-trending") {
+  if (isGithubBoardTemplate(template)) {
     if (Array.isArray((root as any).coverTags)) {
       (root as any).coverTags = (root as any).coverTags
         .filter((t: any) => typeof t === "string" && t.trim())