3
0

10 کامیت‌ها bdb9b21daa ... ee4ec1ebfe

نویسنده SHA1 پیام تاریخ
  lkatzey ee4ec1ebfe perf(deploy): 瘦身镜像 2.85G→2.18G——剥离 .next 构建缓存、消除 chown 重复层 1 روز پیش
  lkatzey 3d91d160a7 feat(publish): 飞书通知增强(标题建议 + @ 成员)与渲染后发布清单 1 روز پیش
  lkatzey 5945456446 feat(github-trending): 优化口播——封面问候+趋势开场、k 星数转中文口语、不念作者名 2 روز پیش
  lkatzey 10d856e462 fix(deploy): 固定容器内 PORT=3000,避免 .env 主机端口泄漏致健康检查失败 3 روز پیش
  lkatzey 159b87477e fix(deploy): 构建期预置 Remotion Chrome 并预建缓存目录,修复容器内渲染 3 روز پیش
  lkatzey 7592fee901 revert(deploy): 恢复 Dockerfile 阿里云 apt/pip 镜像源 3 روز پیش
  lkatzey 28f40b5073 fix(deploy): 容器以 node 用户运行,修复 output/jobs 卷权限 3 روز پیش
  lkatzey 6706b8c9c9 Merge branch 'main' of ssh://gogs.corp.shuidi.tech:30022/incubator/pipeline 4 روز پیش
  lkatzey 6b7ffb8ef0 fix(deploy): 透传全部环境变量到容器 4 روز پیش
  lkatzey 8564d3b3fe fix(web): 读取运行期环境变量,修复容器内设置页为空 4 روز پیش

+ 4 - 0
.env.example

@@ -59,6 +59,10 @@ OSS_ACCESS_KEY_SECRET=
 # When set, results / failures are pushed after each job.
 FEISHU_WEBHOOK_URL=
 # FEISHU_WEBHOOK_SECRET=
+# @mention these members (by open_id) in every pushed message. Custom bots can
+# only @mention by open_id, and it must resolve in the bot's tenant (usually a
+# group member). Comma-separated for multiple, e.g. ou_abc,ou_def
+# FEISHU_AT_OPEN_IDS=
 
 # --- Logging ---
 # Log level for the pipeline / TTS / publish loggers (debug|info|warn|error).

+ 3 - 0
.gitignore

@@ -19,5 +19,8 @@ next-env.d.ts
 output/
 tmp/
 
+# Docker bind-mounted runtime data (jobs store, pre-created as uid 1000)
+.data/
+
 # Claude Code
 .claude/settings.local.json

+ 6 - 3
CLAUDE.md

@@ -39,12 +39,12 @@ packages/templates/ 5 个模板的 Remotion React 组件
 
 输入始终是**结构化 JSON** (`VideoInputSchema`)。流水线在 `packages/core/src/stages/` 中依次执行 6 个阶段:
 
-1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 在这里:①内容场景按 `github.repo.todayStars` 降序取 **top 6**(视频只介绍今日涨星最多的 6 个,顺序与首屏一致);②确定性写入 cover 开场口播(一句承接语 `下面进入项目详解。`,**不含日期/数量**——日期与卡片由首屏视觉呈现);③承载 LLM 产出的 `trendSummary`(一句话趋势)透传到首屏渲染。不再注入 summary 场景,也已停用 `coverTags`
+1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 在这里:①内容场景按 `github.repo.todayStars` 降序取 **top 6**(视频只介绍今日涨星最多的 6 个,顺序与首屏一致);②确定性写入 cover 开场口播(结构:问候 → 今日趋势 → 进入项目详解,即 `大家好,今天{trendSummary}。下面进入项目详解。`,**不含日期/数量**——日期与卡片由首屏视觉呈现;`trendSummary` 缺失时退化为 `大家好,下面进入项目详解。`);③承载 LLM 产出的 `trendSummary`(一句话趋势)透传到首屏渲染。不再注入 summary 场景,也已停用 `coverTags`。parse 末尾还会对所有场景 narration 跑 `normalizeCountsForTTS`,把 `Nk` 星数(如 `11.3k`)展开成中文口语(一万一千三百),避免 TTS 把 k 读成字母
 2. **tts** — 按场景调用 TTS provider,生成逐场景音频文件 + word timestamps
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
 5. **render** — 打包 Remotion bundle,将资源复制到 publicDir,通过 `renderMedia` 渲染
-6. **export** — 输出最终 MP4 到**统一输出目录**,按 `{模板名称}/{ISO日期(YYYY-MM-DD)}/{原文件名}.mp4` 自动建子目录
+6. **export** — 输出最终 MP4 到**统一输出目录**,按 `{模板名称}/{ISO日期(YYYY-MM-DD)}/{原文件名}.mp4` 自动建子目录;同时写一份同名 `.yaml` **发布清单**到 MP4 旁边(标题/描述/标签来自 LLM 产出的 `publish` 块;分区 tid/category 等平台特定字段来自 `config.publishMeta[平台][模板]`)。写清单是 best-effort,失败只告警不中断渲染
 7. **publish**(可选,按需开启)— 渲染成功后上传到阿里云 OSS 并推送飞书 webhook:上传成功推送 OSS 资源链接,生成/上传失败推送失败信息。生成失败(未产出文件)也会推送失败信息。未配置 OSS/飞书时跳过
 
 核心类型链:`VideoInput` → `ParsedContent` → `ComposedProject` → Remotion props。
@@ -100,6 +100,7 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 - `TIMEZONE` — github-trending 首屏日期与输出日期子目录所用时区,默认 `Asia/Shanghai`(Docker 同时据此设 `TZ`)
 - `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` — 阿里云 OSS(密钥走 .env)
 - `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` — 飞书自定义机器人 webhook
+- `FEISHU_AT_OPEN_IDS` — 推送消息时 @ 的成员 open_id(逗号分隔多个)。自定义机器人只能按 open_id @人,且需在机器人所在租户/群内可解析;同时写入消息内 `<at user_id="…">` 标签与顶层 `at.open_ids` 以触发通知
 - `SCHEDULES` — JSON 数组,整体覆盖 `config/default.yaml` 的 `schedules`(部署期改定时任务无需重建镜像)
 - `SCHEDULER_ENABLED` — `false` 关闭容器内进程内调度器(默认开启)
 
@@ -118,8 +119,10 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 
 **新增场景字段时三个 schema 都要同步:** `SceneSchema`(scene.ts)、`ComposedSceneSchema`(pipeline.ts)、`RemotionScene`(Root.tsx 的 interface)。还要在 `compose.ts` 手动透传(不是 `...scene` 展开),否则字段会在 compose 阶段丢失。
 
+**视频级(非场景级)字段另走一条链:** 加在 `VideoInputSchema` + `ParsedContentSchema` + `ComposedProjectSchema`(都是项目顶层,不进 `ComposedSceneSchema`/`RemotionScene`),并在 `parse.ts` 的 `result` 与 `compose.ts` 的返回项目里显式透传。`publish`(发布清单用的 标题/描述/标签)就是这种——不渲染进视频,只由 export 阶段写成 MP4 旁边的 `.yaml`。
+
 ## LLM 与 TTS 提示
 
 - `LLMClient`(`packages/shared/src/llm/client.ts`)设置 `max_tokens: 16384`,`chat()` 返回 `{ content, finishReason }`。github-trending 大 trending 列表可能撑爆 token 上限被截断 → JSON 不完整;parse 阶段对解析失败会**重试一次**(追加精简指令让响应在限额内闭合),错误信息带 `finish_reason` 便于诊断
-- `github-trending` 的 cover narration 是 `parse.ts` 硬编码的一句承接语(`下面进入项目详解。`),**不交给 LLM**,也不含日期/仓库数量。`trendSummary`(一句话趋势)由 LLM 产出并透传到首屏渲染。LLM 只产出 per-repo `scenes[]`(数据源规则要求按今日涨星降序选 6 个),不生成 overview/summary 场景;parse 端会再次按 todayStars 排序取 top6 作兜底。`coverTags` 已停用
+- `github-trending` 的 cover narration 是 `parse.ts` 按模板拼装的(`大家好,今天{trendSummary}。下面进入项目详解。`;无 trendSummary 时退化为 `大家好,下面进入项目详解。`),**不交给 LLM**,也不含日期/仓库数量。`trendSummary`(一句话趋势)由 LLM 产出,**既朗读进封面口播、又作为副标题渲染到首屏**。内容场景的 narration 只念**项目短名**(如 `React`),不念 owner/作者(由提示词约束)。LLM 只产出 per-repo `scenes[]`(数据源规则要求按今日涨星降序选 6 个),不生成 overview/summary 场景;parse 端会再次按 todayStars 排序取 top6 作兜底。`coverTags` 已停用。所有 narration 在 parse 末尾经 `normalizeCountsForTTS` 把 `Nk` 星数转中文口语(视觉卡片的 `formatCount` k 格式不受影响)
 - TTS 按场景独立合成,受 provider 配额限制;调试布局可用 `--no-tts` 跳过(生成静音视频)

+ 51 - 11
Dockerfile

@@ -2,15 +2,16 @@
 FROM node:22-bookworm AS deps
 
 # 使用阿里云镜像源
-# RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
+RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
 
+ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
 RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
 
 RUN apt-get update && \
     apt-get install -y --no-install-recommends ffmpeg python3 python3-pip && \
     rm -rf /var/lib/apt/lists/*
 
-RUN pip3 install --break-system-packages faster-whisper
+RUN pip3 install --break-system-packages -i https://mirrors.aliyun.com/pypi/simple/ faster-whisper
 
 WORKDIR /app
 
@@ -30,13 +31,18 @@ RUN pnpm config set registry https://registry.npmmirror.com && \
 FROM deps AS build
 
 COPY . .
-RUN pnpm build
+# `next start` only reads .next/{server,static,types,*.json}; the 189MB
+# .next/cache is a webpack/swc build cache used solely by `next build`/dev.
+# Drop it here so it never enters the runtime COPY (and never gets re-duplicated
+# by the writable-dirs step below).
+RUN pnpm build && rm -rf apps/web/.next/cache
 
 # Stage 3: Runtime — fresh prod install with hoisted layout
 FROM node:22-bookworm-slim AS runtime
 
-# RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
+RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
 
+ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
 RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
 
 RUN apt-get update && \
@@ -47,7 +53,7 @@ RUN apt-get update && \
       libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
     rm -rf /var/lib/apt/lists/*
 
-RUN pip3 install --break-system-packages faster-whisper
+RUN pip3 install --break-system-packages -i https://mirrors.aliyun.com/pypi/simple/ faster-whisper
 
 WORKDIR /app
 
@@ -75,7 +81,7 @@ RUN mkdir -p node_modules/@pipeline && \
 
 # Copy build artifacts (dist, .next, template src for Remotion entry, etc.)
 COPY --from=build /app/apps/cli/dist apps/cli/dist/
-COPY --from=build /app/apps/web/.next apps/web/.next/
+COPY --from=build --chown=node:node /app/apps/web/.next apps/web/.next/
 COPY --from=build /app/apps/web/next.config.mjs apps/web/
 COPY --from=build /app/apps/web/src apps/web/src/
 COPY --from=build /app/packages/shared/dist packages/shared/dist/
@@ -93,13 +99,47 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \
     cp assets/fonts/NotoSansSC-Regular.ttf assets/fonts/NotoSansSC-Bold.ttf /usr/share/fonts/truetype/noto/ && \
     fc-cache -f
 
+# Bundle Remotion's headless Chrome so rendering needs NO network at runtime.
+# Remotion normally auto-downloads chrome-headless-shell from Google's CDN
+# (storage.googleapis.com / remotion.media) on first render, but that is
+# ~5KB/s from CN networks and stalls indefinitely. Fetch the SAME binary
+# Remotion wants from the Aliyun-backed npmmirror binary mirror at build time
+# and lay it out exactly as Remotion's BrowserFetcher expects, so
+# ensureBrowser() finds it present (revision.local && existsSync(executablePath))
+# and skips the download. Version is read from the installed @remotion/renderer
+# so this auto-tracks Remotion upgrades instead of hardcoding a number.
+RUN set -eux; \
+    SRC=/app/node_modules/@remotion/renderer/dist/browser/get-chrome-download-url.js; \
+    VERSION=$(grep -oE "TESTED_VERSION = '[0-9.]+'" "$SRC" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1); \
+    test -n "$VERSION"; \
+    CACHE=/app/node_modules/.remotion/chrome-headless-shell; \
+    mkdir -p "$CACHE/linux64"; \
+    URL="https://registry.npmmirror.com/-/binary/chrome-for-testing/${VERSION}/linux64/chrome-headless-shell-linux64.zip"; \
+    node -e "const fs=require('fs');fetch(process.argv[1]).then(r=>r.arrayBuffer()).then(b=>fs.writeFileSync(process.argv[2],Buffer.from(b)))" "$URL" /tmp/chs.zip; \
+    python3 -m zipfile -e /tmp/chs.zip "$CACHE/linux64/"; \
+    rm -f /tmp/chs.zip; \
+    BIN="$CACHE/linux64/chrome-headless-shell-linux64/chrome-headless-shell"; \
+    chmod +x "$BIN"; \
+    printf '%s' "$VERSION" > "$CACHE/VERSION"
+
 # Run as the non-root `node` user (uid/gid 1000, shipped in the base image) so
-# the image is Pod Security "restricted"-friendly. Only the writable paths are
-# chowned to keep the layer small: the PVC-backed output, Next.js' .next cache,
-# and a HOME for Remotion's browser / faster-whisper model download cache.
+# the image is Pod Security "restricted"-friendly. We only chown the SMALL,
+# empty runtime-writable dirs (output, HOME, webpack cache). We deliberately do
+# NOT `chown -R` over .next or the Remotion chrome tree: in overlayfs a
+# recursive chown copies every touched file into a fresh layer, which here
+# duplicated ~470MB (.next + chrome) for no functional benefit.
+#   - .next is already node-owned via `COPY --chown` above.
+#   - The Remotion chrome under .remotion is root-owned but world a+rx (default
+#     umask 022 plus the explicit `chmod +x` on the binary), so the node user
+#     can read and execute it. We only chmod the .remotion *directory* 777 so a
+#     stray write (none expected once chrome is pre-placed) can still succeed.
 ENV HOME=/home/node
-RUN mkdir -p /app/output /home/node && \
-    chown -R node:node /app/output /home/node /app/apps/web/.next
+RUN mkdir -p /app/output /home/node \
+      /app/node_modules/.remotion \
+      /app/packages/templates/node_modules/.cache && \
+    chown -R node:node /app/output /home/node \
+      /app/packages/templates/node_modules/.cache && \
+    chmod 777 /app/node_modules/.remotion
 USER node
 
 EXPOSE 3000

+ 1 - 0
apps/cli/src/commands/render.ts

@@ -143,6 +143,7 @@ export const renderCommand = new Command("render")
         retentionDays,
       },
       publish,
+      publishMeta: config?.publishMeta,
       skipPublish: noPublish,
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },

+ 13 - 4
apps/web/next.config.mjs

@@ -1,9 +1,18 @@
 import { existsSync, readFileSync } from "node:fs";
 import { resolve } from "node:path";
 
-// Load .env from monorepo root at build/start time
+// Expose the monorepo-root .env to server-side `process.env.*` reads.
+//
+// IMPORTANT: do NOT put these into `nextConfig.env`. The `env` config key is
+// inlined into the JS bundle at BUILD time, which (a) bakes build-time values
+// into the image and ignores the container's runtime environment
+// (docker-compose `environment:` / k8s envFrom), and (b) inlines secrets into
+// the bundle. Assigning to process.env here runs at config-load (build and
+// `next start`); in the container there is no .env (it isn't copied into the
+// runtime image — see .dockerignore), so this is a no-op there and the
+// container's injected env vars are used as-is. Real/container env always wins
+// — .env only fills keys that are still undefined.
 const envPath = resolve(import.meta.dirname, "../../.env");
-const envVars = {};
 if (existsSync(envPath)) {
   const content = readFileSync(envPath, "utf-8");
   for (const line of content.split("\n")) {
@@ -12,15 +21,15 @@ if (existsSync(envPath)) {
     const eq = trimmed.indexOf("=");
     if (eq < 0) continue;
     const key = trimmed.slice(0, eq).trim();
+    if (!key) continue;
     const val = trimmed.slice(eq + 1).trim();
-    envVars[key] = val;
+    if (process.env[key] === undefined) process.env[key] = val;
   }
 }
 
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   transpilePackages: ["@pipeline/shared", "@pipeline/core", "@pipeline/tts"],
-  env: envVars,
 };
 
 export default nextConfig;

+ 25 - 8
apps/web/src/app/api/settings/route.ts

@@ -58,15 +58,23 @@ function serializeEnv(vars: Record<string, string>, original: string): string {
   return updated.join("\n");
 }
 
+// Keys shown on the Settings page. GET reads these from the LIVE environment
+// (container-injected vars; or .env via next.config in dev) — NOT the .env
+// file, which does not exist in the container. Reading process.env is what
+// makes Settings reflect the actual runtime config instead of appearing empty.
+const SETTINGS_KEYS = [
+  "OPENAI_API_KEY", "OPENAI_BASE_URL",
+  "OPENAI_TTS_API_KEY", "OPENAI_TTS_BASE_URL", "OPENAI_TTS_MODEL",
+  "FISH_AUDIO_API_KEY", "MINIMAX_API_KEY", "MINIMAX_GROUP_ID", "ELEVENLABS_API_KEY",
+  "OSS_REGION", "OSS_BUCKET", "OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_PUBLIC_DOMAIN",
+  "FEISHU_WEBHOOK_URL", "FEISHU_WEBHOOK_SECRET",
+] as const;
+
 export async function GET() {
-  if (!existsSync(ENV_PATH)) {
-    return NextResponse.json({});
-  }
-  const content = readFileSync(ENV_PATH, "utf-8");
-  const parsed = parseEnv(content);
   const masked: Record<string, string> = {};
-  for (const [key, val] of Object.entries(parsed)) {
-    masked[key] = maskValue(key, val);
+  for (const key of SETTINGS_KEYS) {
+    const raw = process.env[key];
+    masked[key] = raw === undefined ? "" : maskValue(key, raw);
   }
   return NextResponse.json(masked);
 }
@@ -89,7 +97,16 @@ export async function POST(request: Request) {
   }
 
   const serialized = serializeEnv(filtered, original);
-  writeFileSync(ENV_PATH, serialized, "utf-8");
+  try {
+    writeFileSync(ENV_PATH, serialized, "utf-8");
+  } catch (e) {
+    // In a container the env is injected externally and .env is usually not
+    // writable (k8s runs as non-root) — surface that clearly instead of a 500.
+    return NextResponse.json(
+      { ok: false, error: `无法写入 .env(容器内环境变量通常由外部注入,不可在此修改): ${(e as Error).message}` },
+      { status: 403 },
+    );
+  }
 
   // Re-read to return masked values
   const reparsed = parseEnv(serialized);

+ 18 - 0
config/default.yaml

@@ -70,6 +70,24 @@ feishu:
   webhook: ""                        # FEISHU_WEBHOOK_URL (use .env)
   # secret: ""                       # signing secret — FEISHU_WEBHOOK_SECRET
 
+# Publish manifest — written as {video}.yaml next to each rendered MP4 with the
+# title/description/tags (LLM-produced for github-trending) plus the
+# platform-specific 分区/category defined here. Lookup: publishMeta[platform][template].
+# Only fill the combos you publish; missing combos just omit those keys.
+publishMeta:
+  bilibili:
+    github-trending:
+      tid: 122                  # 分区 id (122 = 科技→科普). Adjust to your target board.
+      tags: ["GitHub", "开源", "热榜"]
+  douyin-long:
+    github-trending:
+      category: "科技"
+      tags: ["GitHub", "开源"]
+  douyin-short:
+    github-trending:
+      category: "科技"
+      tags: ["GitHub", "开源"]
+
 templates:
   news:
     primaryColor: "#1a56db"

+ 6 - 0
deploy/k8s/01-configmap.yaml

@@ -14,3 +14,9 @@ data:
   TZ: Asia/Shanghai
   LOG_LEVEL: info
   NODE_ENV: production
+  # Scheduled renders — the in-process scheduler (instrumentation + node-cron)
+  # reads `schedules` from config/default.yaml by default (baked into the
+  # image). Override with SCHEDULES (JSON array, same shape) or disable the
+  # scheduler with SCHEDULER_ENABLED=false.
+  SCHEDULER_ENABLED: "true"
+  # SCHEDULES: '[{"cron":"50 7 * * *","template":"github-trending","platform":"bilibili","source":"github-trending","publish":true}]'

+ 29 - 30
docker-compose.yml

@@ -1,41 +1,43 @@
 services:
   pipeline:
     build: .
-    # The image runs as the non-root `node` user for Kubernetes. For local dev
-    # we override back to root so the ./output bind mount keeps working
-    # regardless of the host user's uid; Kubernetes sets fsGroup instead.
-    user: "0:0"
+    # Run as the image's non-root `node` user by default (uid 1000 == host
+    # `lkattey` here, and the same user Kubernetes runs the image as, so local
+    # compose mirrors prod's filesystem ownership). Bind-mounted files (./output,
+    # ./.data) therefore stay host-accessible instead of root-owned. On a host
+    # whose uid isn't 1000, set HOST_UID/HOST_GID (in .env or the shell) to your
+    # numeric uid:gid so the bind mounts match your user.
+    user: "${HOST_UID:-node}:${HOST_GID:-node}"
     ports:
       - "${PORT:-13000}:3000"
+    # Pass EVERY variable from .env (API keys, OSS, Feishu, SCHEDULES,
+    # SCHEDULER_ENABLED, LOG_LEVEL, …) into the container automatically via
+    # env_file — no per-variable list to maintain. The previous explicit list
+    # silently dropped any var not enumerated, so new knobs set in .env never
+    # reached the container. `environment` below only adds container-specific
+    # defaults for keys that may be absent from .env, and overrides env_file for
+    # those keys.
+    env_file:
+      - .env
     environment:
-      - OPENAI_API_KEY=${OPENAI_API_KEY}
-      - OPENAI_BASE_URL=${OPENAI_BASE_URL}
-      - OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY}
-      - OPENAI_TTS_BASE_URL=${OPENAI_TTS_BASE_URL}
-      - FISH_AUDIO_API_KEY=${FISH_AUDIO_API_KEY}
-      - MINIMAX_API_KEY=${MINIMAX_API_KEY}
-      - MINIMAX_GROUP_ID=${MINIMAX_GROUP_ID}
-      - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
-      # Unified output + cache retention
+      # PIN container's listening port to 3000. `PORT` in .env is the HOST
+      # port for the mapping above ("${PORT:-13000}:3000") — it must NOT reach
+      # Next.js inside the container, or `next start` reads PORT=13000 and
+      # listens on the wrong port, so both the 13000->3000 mapping and the
+      # HEALTHCHECK (which probes :3000) miss it → container goes unhealthy
+      # and the WebUI is unreachable. `environment` overrides `env_file`, so
+      # this neutralizes the PORT leaked by env_file below.
+      - PORT=3000
       - OUTPUT_DIR=${OUTPUT_DIR:-/app/output}
       - OUTPUT_RETENTION_DAYS=${OUTPUT_RETENTION_DAYS:-30}
-      # Container timezone — keeps logs, ffmpeg, and new Date() on Beijing time
-      # so the daily-trending cover date never rolls over a day early/late.
       - TZ=${TZ:-Asia/Shanghai}
-      # OSS (Alibaba Cloud Object Storage) — publish stage
-      - OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID:-}
-      - OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET:-}
-      - OSS_REGION=${OSS_REGION:-}
-      - OSS_BUCKET=${OSS_BUCKET:-}
-      - OSS_ENDPOINT=${OSS_ENDPOINT:-}
-      - OSS_PATH=${OSS_PATH:-}
-      - OSS_PUBLIC_DOMAIN=${OSS_PUBLIC_DOMAIN:-}
-      # Feishu custom-bot webhook — publish stage
-      - FEISHU_WEBHOOK_URL=${FEISHU_WEBHOOK_URL:-}
-      - FEISHU_WEBHOOK_SECRET=${FEISHU_WEBHOOK_SECRET:-}
+      - TIMEZONE=${TIMEZONE:-Asia/Shanghai}
     volumes:
       - ./output:/app/output
-      - pipeline-jobs:/tmp/pipeline-jobs
+      # Bind mount (pre-created as uid 1000 on the host) instead of a named
+      # volume: named volumes are root-owned, which the non-root container
+      # (uid 1000) below couldn't write to. .data is gitignored.
+      - ./.data/pipeline-jobs:/tmp/pipeline-jobs
     # Use public DNS resolvers directly. The container inherits the host's
     # resolver, which can silently fail to resolve some public domains (e.g.
     # open.feishu.cn behind a VPN/proxy) and break the Feishu webhook. Override
@@ -44,6 +46,3 @@ services:
       - ${DNS_SERVER_1:-223.5.5.5}
       - ${DNS_SERVER_2:-114.114.114.114}
     restart: unless-stopped
-
-volumes:
-  pipeline-jobs:

+ 2 - 0
docs/DEPLOYMENT.md

@@ -97,6 +97,7 @@ docker compose up --build -d        # 改完代码后重新构建
 | `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` | 否 | 阿里云 OSS;四项齐全才启用上传 |
 | `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` | 否 | OSS 自定义端点 / key 前缀 / 资源链接域名(如 CDN) / 是否 HTTPS |
 | `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
+| `FEISHU_AT_OPEN_IDS` | 否 | 推送消息时 @ 的成员 open_id(逗号分隔多个)。自定义机器人只能按 open_id @人,且需在机器人租户/群内可解析 |
 | `LOG_LEVEL` | 否 | 日志级别 `debug\|info\|warn\|error`,默认 `info` |
 | `PORT` | 否 | 宿主机映射端口,默认 13000 |
 | `SCHEDULES` | 否 | 定时任务(JSON 数组),整体覆盖 `config/default.yaml` 的 `schedules`,便于部署期改而无需重建镜像 |
@@ -149,6 +150,7 @@ schedules:
 
 - OSS 用 `ali-oss` 分片上传;资源链接优先用 `OSS_PUBLIC_DOMAIN`(CDN),否则用 bucket 默认域名。**假定对象为公开读**;若为私读需另行接签名 URL。
 - 飞书支持自定义机器人的**签名校验**(`FEISHU_WEBHOOK_SECRET`),算法为 `base64(HMAC-SHA256(key=timestamp+"\n"+secret, message=""))`。
+- 设置 `FEISHU_AT_OPEN_IDS`(逗号分隔的 open_id)后,每条推送都会 @ 这些成员:消息正文追加 `<at user_id="open_id">` 标签,并在 payload 顶层声明 `at.open_ids`(二者齐备才会真正触发 @ 通知)。注意自定义机器人只能按 **open_id** @人(无通讯录权限,不支持 user_id / 邮箱),且 open_id 须在该机器人租户内可解析(通常为目标群成员)。
 - 上传后的 OSS URL 会回填到导出文件(CLI 打印 `oss: <url>`;WebUI 任务详情页展示「OSS Links」)。
 
 ---

+ 2 - 1
packages/core/package.json

@@ -23,7 +23,8 @@
     "@remotion/bundler": "^4.0.0",
     "ali-oss": "^6.21.0",
     "zod": "^3.24.0",
-    "tmp-promise": "^3.0.3"
+    "tmp-promise": "^3.0.3",
+    "yaml": "^2.7.0"
   },
   "devDependencies": {
     "@types/node": "^22.0.0"

+ 36 - 3
packages/core/src/pipeline.ts

@@ -13,7 +13,7 @@ import { generateTTS } from "./stages/tts.js";
 import { resolveAssets } from "./stages/assets.js";
 import { composeProject } from "./stages/compose.js";
 import { renderVideo } from "./stages/render.js";
-import { exportVideo } from "./stages/export.js";
+import { exportVideo, type PublishTargetConfig } from "./stages/export.js";
 import { cleanupExpiredOutput } from "./cleanup.js";
 import {
   runPublish,
@@ -49,6 +49,12 @@ export interface PipelineConfig {
   };
   /** OSS upload + Feishu notification, resolved by resolvePublishConfig. */
   publish?: PublishConfig;
+  /**
+   * Per-platform × per-template publish metadata for the sidecar manifest
+   * (partition tid / category, extra tags). Lookup: publishMeta[platform][template].
+   * Missing combos simply omit those keys from the manifest.
+   */
+  publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
   /** Skip the publish stage entirely (e.g. local debugging). */
   skipPublish?: boolean;
   assets: {
@@ -68,6 +74,20 @@ export interface PipelineCallbacks {
   onError?: (stage: string, error: Error) => void;
 }
 
+/**
+ * Compose a suggested video title for Feishu notifications: the parsed title,
+ * joined with the cover scene's one-line trendSummary (github-trending) via a
+ * fullwidth separator when present; otherwise just the title. Returns undefined
+ * when no title is known (e.g. parse failed) so the line can be omitted.
+ */
+function buildTitleSuggestion(
+  title: string | undefined,
+  trendSummary: string | undefined
+): string | undefined {
+  if (!title) return undefined;
+  return trendSummary ? `${title}|${trendSummary}` : title;
+}
+
 export async function runPipeline(
   input: PipelineInput,
   config: PipelineConfig,
@@ -167,7 +187,8 @@ export async function runPipeline(
         renderOutput,
         composed,
         config.output.dir,
-        jobId
+        jobId,
+        config.publishMeta?.[platform]?.[input.template]
       );
       const expFile = exported.files[0];
       log.info(
@@ -191,6 +212,10 @@ export async function runPipeline(
         jobId,
         template: input.template,
         platforms,
+        titleSuggestion: buildTitleSuggestion(
+          parsed.title,
+          parsed.scenes[0]?.trendSummary
+        ),
       };
       try {
         const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
@@ -226,7 +251,15 @@ export async function runPipeline(
     if (!config.skipPublish && config.publish) {
       await notifyGenerationFailure(
         config.publish,
-        { jobId, template: input.template, platforms },
+        {
+          jobId,
+          template: input.template,
+          platforms,
+          titleSuggestion: buildTitleSuggestion(
+            job.parsed?.title,
+            job.parsed?.scenes?.[0]?.trendSummary
+          ),
+        },
         job.error ?? "unknown error"
       ).catch(() => {});
     }

+ 22 - 1
packages/core/src/publish/feishu.ts

@@ -9,6 +9,13 @@ export interface FeishuConfig {
   webhook: string;
   /** Optional signing secret (custom-bot "签名校验"). */
   secret?: string;
+  /**
+   * Optional open_id list to @mention in every pushed message. Custom bots can
+   * only @mention by open_id (no contact permission for user_id/email), and the
+   * open_id must resolve in the bot's tenant — usually a member of the target
+   * group. Configured via FEISHU_AT_OPEN_IDS (comma-separated).
+   */
+  atOpenIds?: string[];
 }
 
 /**
@@ -34,10 +41,24 @@ export async function sendFeishuMessage(config: FeishuConfig, text: string): Pro
     url = `${url}${sep}timestamp=${timestamp}&sign=${encodeURIComponent(signature)}`;
   }
 
+  // Custom bots need BOTH the inline <at> tag (renders the mention) and the
+  // top-level `at.open_ids` array (triggers the notification) for an @ to fire.
+  const atOpenIds = config.atOpenIds ?? [];
+  const bodyText = atOpenIds.length
+    ? `${text}\n${atOpenIds.map((id) => `<at user_id="${id}"></at>`).join(" ")}`
+    : text;
+  const payload: Record<string, unknown> = {
+    msg_type: "text",
+    content: { text: bodyText },
+  };
+  if (atOpenIds.length) {
+    payload.at = { open_ids: atOpenIds };
+  }
+
   const res = await fetch(url, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ msg_type: "text", content: { text } }),
+    body: JSON.stringify(payload),
   });
 
   if (!res.ok) {

+ 34 - 3
packages/core/src/publish/index.ts

@@ -16,6 +16,14 @@ export interface PublishContext {
   jobId: string;
   template: string;
   platforms: string[];
+  /**
+   * Suggested video title for the Feishu message, e.g.
+   * "GitHub 每日热榜|AI Agent 框架持续火热". Combined from the parsed title and
+   * the cover scene's one-line trendSummary (github-trending) when present;
+   * otherwise just the title. Omitted entirely when no title is known
+   * (e.g. parse failed before a title existed).
+   */
+  titleSuggestion?: string;
 }
 
 export interface UploadedFile {
@@ -37,6 +45,13 @@ function parseBool(value: string | undefined, fallback: boolean): boolean {
   return value === "1" || value.toLowerCase() === "true";
 }
 
+/** Parse a comma-separated string OR string[] of open_ids into a clean list. */
+function parseOpenIdList(value: unknown): string[] {
+  if (!value) return [];
+  const arr = Array.isArray(value) ? value : String(value).split(",");
+  return arr.map((v) => String(v).trim()).filter(Boolean);
+}
+
 /**
  * Build a PublishConfig from the raw YAML config merged with environment
  * variables. Env vars take precedence and are the recommended place for secrets.
@@ -67,9 +82,11 @@ export function resolvePublishConfig(
 
   const feishuRaw = raw?.feishu ?? {};
   const webhook = process.env.FEISHU_WEBHOOK_URL || feishuRaw.webhook || "";
+  const atOpenIds = parseOpenIdList(process.env.FEISHU_AT_OPEN_IDS ?? feishuRaw.atOpenIds);
   const feishu: FeishuConfig = {
     webhook,
     secret: process.env.FEISHU_WEBHOOK_SECRET || feishuRaw.secret,
+    ...(atOpenIds.length ? { atOpenIds } : {}),
   };
   const feishuEnabled = Boolean(feishu.webhook);
 
@@ -93,6 +110,11 @@ async function safeNotify(config: FeishuConfig, text: string): Promise<boolean>
   }
 }
 
+/** "标题建议: …" line, or null when no title is known (so it can be filtered out). */
+function titleLine(ctx: PublishContext): string | null {
+  return ctx.titleSuggestion ? `标题建议: ${ctx.titleSuggestion}` : null;
+}
+
 function buildSuccessText(
   ctx: PublishContext,
   uploaded: UploadedFile[],
@@ -103,12 +125,15 @@ function buildSuccessText(
     : files.map((f) => `- [${f.platform}] ${f.filePath}`);
   return [
     "✅ 视频生成成功",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     "资源:",
     ...lines,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 function buildUploadFailureText(
@@ -118,22 +143,28 @@ function buildUploadFailureText(
 ): string {
   return [
     "⚠️ 视频已生成,但 OSS 上传失败",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     `本地文件: ${files.map((f) => f.filePath).join(", ")}`,
     `错误: ${error}`,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 function buildGenerationFailureText(ctx: PublishContext, error: string): string {
   return [
     "❌ 视频生成失败",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     `错误: ${error}`,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 /**

+ 1 - 0
packages/core/src/stages/compose.ts

@@ -90,5 +90,6 @@ export function composeProject(
     subtitle: parsed.subtitle,
     outro: parsed.outro,
     channelName,
+    publish: parsed.publish,
   };
 }

+ 69 - 4
packages/core/src/stages/export.ts

@@ -1,14 +1,67 @@
 import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
 import { isoDateString } from "@pipeline/shared";
-import { getTimezone } from "@pipeline/shared/node";
+import { getTimezone, createLogger } from "@pipeline/shared/node";
 import { join } from "node:path";
-import { mkdir } from "node:fs/promises";
+import { mkdir, writeFile } from "node:fs/promises";
+import { stringify as stringifyYaml } from "yaml";
+
+const log = createLogger("export");
+
+/**
+ * Per-platform × per-template publish config (from `config.publishMeta`).
+ * Everything except `tags` is emitted verbatim under the manifest's `category`
+ * block, so platform-specific partition keys (bilibili `tid`, douyin `category`,
+ * …) pass through without the code needing to know them.
+ */
+export interface PublishTargetConfig {
+  tags?: string[];
+  tid?: number;
+  category?: string;
+  [key: string]: unknown;
+}
+
+/** Build the sidecar manifest object for one rendered file. Exported for tests. */
+export function buildPublishManifest(
+  project: ComposedProject,
+  fileBasename: string,
+  publishMeta?: PublishTargetConfig
+): Record<string, unknown> {
+  const publish = project.publish;
+  const configTags = publishMeta?.tags ?? [];
+  const llmTags = publish?.tags ?? [];
+  // Merge LLM tags first, then config tags; drop empties + dedupe (case-sensitive).
+  const tags = [...llmTags, ...configTags]
+    .filter((t): t is string => typeof t === "string" && t.length > 0)
+    .filter((t, i, arr) => arr.indexOf(t) === i);
+
+  // category = everything in publishMeta except `tags` (tid / category / …).
+  const category: Record<string, unknown> = {};
+  if (publishMeta) {
+    for (const [k, v] of Object.entries(publishMeta)) {
+      if (k !== "tags") category[k] = v;
+    }
+  }
+
+  const manifest: Record<string, unknown> = {
+    title: publish?.title || project.title || "",
+    description: publish?.description || "",
+    tags,
+    platform: project.platform,
+    template: project.template,
+    file: `${fileBasename}.mp4`,
+    durationSeconds: Number((project.durationInFrames / project.fps).toFixed(2)),
+    date: isoDateString(new Date(), getTimezone()),
+  };
+  if (Object.keys(category).length > 0) manifest.category = category;
+  return manifest;
+}
 
 export async function exportVideo(
   renderedPath: string,
   project: ComposedProject,
   outputDir: string,
-  jobId: string
+  jobId: string,
+  publishMeta?: PublishTargetConfig
 ): Promise<ExportManifest> {
   // Unified layout: {outputDir}/{template}/{ISO-date}/{file}. Date is in the
   // configured timezone (default Asia/Shanghai) so the folder matches the date
@@ -19,10 +72,22 @@ export async function exportVideo(
   const { stat, copyFile } = await import("node:fs/promises");
   const statResult = await stat(renderedPath);
 
-  const filename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}.mp4`;
+  const fileBasename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}`;
+  const filename = `${fileBasename}.mp4`;
   const finalPath = join(subDir, filename);
   await copyFile(renderedPath, finalPath);
 
+  // Sidecar publish manifest (YAML), same basename as the MP4. Best-effort: a
+  // manifest failure must never abort a successful render.
+  try {
+    const manifestPath = join(subDir, `${fileBasename}.yaml`);
+    const manifest = buildPublishManifest(project, fileBasename, publishMeta);
+    await writeFile(manifestPath, stringifyYaml(manifest), "utf8");
+    log.info(`publish manifest -> ${manifestPath}`);
+  } catch (err) {
+    log.warn(`publish manifest write failed (render still succeeded): ${err instanceof Error ? err.message : err}`);
+  }
+
   const files: ExportFile[] = [
     {
       platform: project.platform,

+ 48 - 4
packages/core/src/stages/parse.ts

@@ -7,6 +7,7 @@ import {
   stripUnsupportedGlyphs,
   clipToLength,
   formatChineseDate,
+  normalizeCountsForTTS,
 } from "@pipeline/shared";
 import { getTimezone } from "@pipeline/shared/node";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
@@ -35,9 +36,9 @@ export interface ParseStageConfig {
  * returns it; the input is left untouched.
  *
  * When `template === "github-trending"`, also strips leading greetings from
- * every scene's narration — the cover scene already opens with "大家好,今天
- * 是..." so any per-scene greeting would duplicate it. LLM adherence to the
- * prompt rule is unreliable, so this is the source of truth.
+ * every scene's narration — the cover scene already opens with a greeting
+ * ("大家好,…") so any per-scene greeting would duplicate it. LLM adherence to
+ * the prompt rule is unreliable, so this is the source of truth.
  */
 function applyLengthLimits(input: unknown, template: string): unknown {
   if (!input || typeof input !== "object") return input;
@@ -77,6 +78,21 @@ function applyLengthLimits(input: unknown, template: string): unknown {
     if (typeof (root as any).trendSummary === "string") {
       (root as any).trendSummary = clipToLength((root as any).trendSummary, 40);
     }
+    // Clip the publish sidecar metadata too (description ≤200; ≤8 tags, each
+    // ≤20) so an over-budget LLM response still validates instead of failing.
+    if ((root as any).publish && typeof (root as any).publish === "object") {
+      const pub: any = { ...(root as any).publish };
+      if (typeof pub.description === "string") {
+        pub.description = clipToLength(pub.description, 200);
+      }
+      if (Array.isArray(pub.tags)) {
+        pub.tags = pub.tags
+          .filter((t: any) => typeof t === "string" && t.trim())
+          .map((t: any) => clipToLength(t.trim(), 20))
+          .slice(0, 8);
+      }
+      (root as any).publish = pub;
+    }
   }
   return root;
 }
@@ -140,6 +156,16 @@ function sanitizeVideoInput(input: VideoInput): VideoInput {
       ? input.coverTags.map((t) => stripUnsupportedGlyphs(t ?? ""))
       : input.coverTags,
     trendSummary: input.trendSummary ? stripUnsupportedGlyphs(input.trendSummary) : input.trendSummary,
+    publish: input.publish
+      ? {
+          ...input.publish,
+          title: stripUnsupportedGlyphs(input.publish.title ?? ""),
+          description: stripUnsupportedGlyphs(input.publish.description ?? ""),
+          tags: Array.isArray(input.publish.tags)
+            ? input.publish.tags.map((t) => stripUnsupportedGlyphs(t ?? ""))
+            : input.publish.tags,
+        }
+      : input.publish,
     cover: input.cover
       ? { ...input.cover, keyframes: cleanKeyframes(input.cover.keyframes) }
       : input.cover,
@@ -289,8 +315,17 @@ export async function parseText(
         .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
         .slice(0, 6)
     : videoInput.scenes;
+  // Cover opens: greeting → today's trend (spoken from the LLM-produced
+  // trendSummary) → transition into the repo rundown. No date/count (shown
+  // visually) and no verbose welcome. Falls back to a bare greeting+transition
+  // when trendSummary is absent.
+  const trend = (videoInput.trendSummary ?? "")
+    .trim()
+    .replace(/[。!?.!?\s]+$/u, "");
   const coverNarration = template === "github-trending"
-    ? "下面进入项目详解。"
+    ? trend
+      ? `大家好,今天${trend}。下面进入项目详解。`
+      : "大家好,下面进入项目详解。"
     : "";
   scenes.push({
     id: "cover",
@@ -335,6 +370,14 @@ export async function parseText(
     });
   }
 
+  // Expand "Nk" star/fork counts in every narration to spoken Chinese before
+  // TTS reads them aloud (e.g. "11.3k" → 一万一千三百). Done here so the spoken
+  // form feeds both audio synthesis and subtitle word-timestamps consistently.
+  // Visual card counts (formatCount on numeric fields) are unaffected.
+  for (const sc of scenes) {
+    if (sc.narration) sc.narration = normalizeCountsForTTS(sc.narration);
+  }
+
   const result: ParsedContent = {
     title: videoInput.title,
     subtitle: videoInput.subtitle ?? videoInput.scenes[0]?.title,
@@ -343,6 +386,7 @@ export async function parseText(
     scenes,
     outro: videoInput.outro,
     globalStyle: videoInput.globalStyle || { tone: "formal", pace: "normal" },
+    publish: videoInput.publish,
   };
 
   return ParsedContentSchema.parse(result);

+ 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, DEFAULT_TIMEZONE } from "./utils/index.js";
+export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, normalizeCountsForTTS, DEFAULT_TIMEZONE } from "./utils/index.js";

+ 11 - 3
packages/shared/src/llm/prompts/parse-text.ts

@@ -11,6 +11,7 @@ The output JSON must follow this exact schema:
   "title": "string — video title, concise and catchy",
   "summary": "string — one-sentence summary",
   "trendSummary": "string — OPTIONAL, github-trending only. One short sentence (≤30 chars) synthesizing the day's overall trend. Omit for other templates.",
+  "publish": "object — OPTIONAL, github-trending only. Posting metadata for the video page: { title, description, tags[] }. See template-specific rules. Omit for other templates.",
   "scenes": [
     {
       "id": "scene-N",
@@ -97,6 +98,11 @@ Template: GitHub Trending Showcase (GitHub 每日热榜)
 COVER METADATA (github-trending 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 today's repos — what theme is hot or emerging. 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-trending 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 简介与标签), 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 day'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 today'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.
@@ -116,10 +122,12 @@ github.review (点评推荐): one short recommendation, ≤ 30 Chinese character
 
 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.
 
-GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with a short factual masthead line ("…,今日精选 N 个热榜项目。") that frames the day. Therefore EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
+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, show, or format: "今天是GitHub热榜速览", "今天为大家带来...", "今天给大家介绍...", "本期节目...", "本周热榜...", "欢迎收看...".
-- Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是 Facebook 出品的声明式 UI 库...").
+- Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是一款声明式 UI 库...").
+
+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 (it is unclear whether you mean the person/org or the project). 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.
 
@@ -161,7 +169,7 @@ B. Keyframe cards (4 cards ideally, 3-4 acceptable). Pick content that ADDS valu
    Number formatting rule (STRICT): whenever a count >= 1000 appears (stars, forks, contributors, today's growth, etc.), display it with a lowercase k suffix, NOT the raw integer. Examples: 1234 → "1.2k", 12345 → "12.3k", 123456 → "123k", 1234567 → "1235k", 999 → "999" (unchanged). NEVER use the m suffix — counts stay in k even past one million. The input text from the collector is already in this format — preserve it; if you ever compute or restate a count yourself, apply the same formatting before writing it into a card.
 
 C. 详细描述 → narration (口播)
-   Must be information-dense within the 200-char limit. Cover: what the project does (deeper than the headline), why it is notable, and 1-2 distinctive characteristics a developer would care about. Pack specifics — numbers, comparisons, concrete use cases — instead of filler phrases. Do NOT include a language bullet; mention the language only in passing if it is core to the value (e.g. "用 Rust 实现,启动速度比 Node 同类快数倍").
+   Must be information-dense within the 200-char limit. Cover: what the project does (deeper than the headline), why it is notable, and 1-2 distinctive characteristics a developer would care about. Pack specifics — numbers, comparisons, concrete use cases — instead of filler phrases. Refer to the project by its SHORT repo name only — do NOT read the owner/author in narration (e.g. say "React", not "Facebook 的 React" or "facebook/react"); the owner is already shown on the GitHub 地址 card, and reading it aloud can cause ambiguity. Do NOT include a language bullet; mention the language only in passing if it is core to the value (e.g. "用 Rust 实现,启动速度比 Node 同类快数倍").
    If the content does not fit in 200 chars, split the project across 2 scenes — first scene uses the structure above with concise narration; second scene expands with deeper detail on features, comparisons, or ecosystem.
 
 A typical single-project scene therefore looks like: short displayText (项目名 [+ 极简类别]) + 3 mandatory keyframe cards (功能简介 / GitHub 地址 / 使用场景) + 1-2 value-add cards + dense narration (口播详细描述).

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

@@ -7,6 +7,7 @@ export {
   GlobalStyleHintsSchema,
   InputSceneSchema,
   VideoInputSchema,
+  PublishMetaSchema,
   SceneSchema,
   ParsedContentSchema,
   RepoMetaSchema,
@@ -21,6 +22,7 @@ export type {
   GlobalStyleHints,
   InputScene,
   VideoInput,
+  PublishMeta,
   Scene,
   ParsedContent,
   RepoMeta,

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

@@ -1,5 +1,5 @@
 import { z } from "zod";
-import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema } from "./scene.js";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema, PublishMetaSchema } from "./scene.js";
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
@@ -102,6 +102,9 @@ export const ComposedProjectSchema = z.object({
     })
     .optional(),
   channelName: z.string().default("Pipeline"),
+  // Threaded from parsed.publish (github-trending) → written to the sidecar
+  // manifest by the export stage. Optional; absent for other templates.
+  publish: PublishMetaSchema.optional(),
 });
 
 export type ComposedProject = z.infer<typeof ComposedProjectSchema>;

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

@@ -104,6 +104,20 @@ export const InputSceneSchema = z.object({
 
 export type InputScene = z.infer<typeof InputSceneSchema>;
 
+// --- Publish metadata (LLM-produced for github-trending) ---
+// Video-level sidecar info for posting the rendered video: a posting title,
+// a 简介/description, and 话题 tags. NOT rendered into the video — it is written
+// to a YAML manifest next to the MP4 by the export stage. Platform-specific
+// fields (分区 tid / category) come from config, not here.
+
+export const PublishMetaSchema = z.object({
+  title: z.string(),
+  description: z.string(),
+  tags: z.array(z.string()).max(10).optional(),
+});
+
+export type PublishMeta = z.infer<typeof PublishMetaSchema>;
+
 // --- Top-level video input ---
 
 export const VideoInputSchema = z.object({
@@ -114,6 +128,9 @@ export const VideoInputSchema = z.object({
   // the cover scene. LLM-produced, surfaced on the opening title screen.
   coverTags: z.array(z.string()).max(5).optional(),
   trendSummary: z.string().max(40).optional(),
+  // github-trending only: posting metadata (title/description/tags) written to
+  // the sidecar manifest. LLM-produced; omitted by other templates.
+  publish: PublishMetaSchema.optional(),
   cover: VideoCoverSchema.optional(),
   scenes: z.array(InputSceneSchema).min(1),
   outro: VideoOutroSchema.optional(),
@@ -156,6 +173,9 @@ export const ParsedContentSchema = z.object({
   scenes: z.array(SceneSchema),
   outro: VideoOutroSchema.optional(),
   globalStyle: GlobalStyleHintsSchema,
+  // Threaded from VideoInput.publish (github-trending) through to the export
+  // stage's sidecar manifest. Optional — absent for templates without it.
+  publish: PublishMetaSchema.optional(),
 });
 
 export type ParsedContent = z.infer<typeof ParsedContentSchema>;

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

@@ -1,3 +1,4 @@
 export { stripUnsupportedGlyphs, clipToLength } from "./sanitize-text.js";
 export { formatCount } from "./format.js";
 export { isoDateString, formatChineseDate, DEFAULT_TIMEZONE } from "./date.js";
+export { normalizeCountsForTTS } from "./narration.js";

+ 102 - 0
packages/shared/src/utils/narration.ts

@@ -0,0 +1,102 @@
+/**
+ * Normalize narration text so a TTS engine reads counts aloud as natural
+ * spoken Chinese instead of a literal ASCII transcription.
+ *
+ * GitHub star/fork counts are written with a lowercase "k" suffix everywhere
+ * (e.g. "11.3k", "123k") to match the on-screen cards (see formatCount). But a
+ * TTS engine fed "11.3k" reads it literally — "十一点三k" or "十一点三千" — which
+ * sounds wrong. This expands every "Nk" token to its full integer value in
+ * spoken Chinese ("11.3k" → 一万一千三百, "2k" → 两千, "20k" → 两万), leaving
+ * everything else untouched: plain numbers stay (TTS reads "312" as 三百一十二
+ * fine), version strings ("React 18"), repo names, and the visual "k" formatting
+ * on cards are all unaffected. Applied to narration only.
+ *
+ * Follows the colloquial 两/二 rule: 2 reads 两 before 千/百/万/亿 (两千, 两百,
+ * 两万, 两亿) but 二 in the tens/ones place (二十, 十二, 二十二). */
+
+const ZH_DIGITS = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
+
+/** Convert a 1..9999 group to Chinese place-value form, collapsing internal
+ *  zeros to a single 零 (e.g. 1001 → 一千零一, 1300 → 一千三百). "一十" is
+ *  shortened to "十" only when the tens place is the group's leading position. */
+function fourDigitsToZh(n: number): string {
+  const places = ["千", "百", "十", ""];
+  const digits = [
+    Math.floor(n / 1000) % 10,
+    Math.floor(n / 100) % 10,
+    Math.floor(n / 10) % 10,
+    n % 10,
+  ];
+  const parts: string[] = [];
+  let started = false;
+  let pendingZero = false;
+  for (let i = 0; i < 4; i++) {
+    const d = digits[i];
+    if (d === 0) {
+      if (started) pendingZero = true;
+      continue;
+    }
+    if (pendingZero) {
+      parts.push("零");
+      pendingZero = false;
+    }
+    if (i === 2 && d === 1 && !started) {
+      parts.push("十");
+    } else {
+      // Colloquial 两/二 rule: 千位/百位的 2 reads 两 (两千/两百); 十位/个位
+      // read 二 (二十/十二). So 2000→两千, 2200→两千两百, but 20→二十, 22→二十二.
+      const word = d === 2 && (i === 0 || i === 1) ? "两" : ZH_DIGITS[d];
+      parts.push(word + places[i]);
+    }
+    started = true;
+  }
+  return parts.join("");
+}
+
+/** Convert a non-negative integer to spoken Chinese (supports up to 亿 scale,
+ *  which covers any k-suffixed count: even 9999k ≈ 一亿). */
+function integerToZh(n: number): string {
+  n = Math.trunc(n);
+  if (n === 0) return "零";
+  const units = ["", "万", "亿"];
+  const groups: number[] = [];
+  while (n > 0) {
+    groups.push(n % 10000);
+    n = Math.floor(n / 10000);
+  }
+  let result = "";
+  let seenNonZero = false;
+  let zeroGap = false; // a higher group was emitted; a lower group is empty
+  for (let i = groups.length - 1; i >= 0; i--) {
+    const g = groups[i];
+    if (g === 0) {
+      if (seenNonZero) zeroGap = true;
+      continue;
+    }
+    // Insert 零 when a non-zero group doesn't fill its 千 place, or when an
+    // interior all-zero group separates two non-zero groups (e.g. 1亿零3千).
+    if (zeroGap || (seenNonZero && g < 1000)) result += "零";
+    const unit = units[i];
+    // A lone 2 multiplying 万/亿 reads 两 (两万/两亿), not 二万/二亿. Larger group
+    // values are handled by fourDigitsToZh (which already uses 两 in 百/千 place).
+    if (g === 2 && unit) result += "两" + unit;
+    else result += fourDigitsToZh(g) + unit;
+    seenNonZero = true;
+    zeroGap = false;
+  }
+  return result;
+}
+
+// Match "11.3k", "12.3K", "123k", "1 k" — the k must NOT be part of a larger
+// word (reject when followed by a letter/digit, so "k8s" / "key" are untouched).
+const K_COUNT_PATTERN = /(\d+(?:\.\d+)?)\s*[kK](?![a-zA-Z0-9])/g;
+
+/** Expand every "Nk" count token in `text` to spoken Chinese. */
+export function normalizeCountsForTTS(text: string): string {
+  if (typeof text !== "string" || text.length === 0) return text;
+  return text.replace(K_COUNT_PATTERN, (match, num) => {
+    const value = Math.round(Number(num) * 1000);
+    if (!Number.isFinite(value) || value <= 0) return match;
+    return integerToZh(value);
+  });
+}

+ 3 - 0
pnpm-lock.yaml

@@ -128,6 +128,9 @@ importers:
       tmp-promise:
         specifier: ^3.0.3
         version: 3.0.3
+      yaml:
+        specifier: ^2.7.0
+        version: 2.9.0
       zod:
         specifier: ^3.24.0
         version: 3.25.76