Przeglądaj źródła

refactor: 项目解耦——三模块 + 单一 VideoDocument 契约 + 核心服务进程内化

将 runPipeline 的 7 阶段拆成 text/audio/renderer 三个解耦模块,模块间只通过
VideoDocument 通信(Zod 校验);核心逻辑从 spawn CLI 子进程内化到 Next.js 服务
进程内。CLI/WebUI 退化为薄客户端。参照 docs/项目解耦 的需求。

契约 (packages/shared):
- 新增 VideoDocument/VideoSegment (types/document.ts) 与渲染期 RenderScene/RenderProps
  (types/render.ts)。
- 通用 images 只含 path/url/query;模板专属数据走 extension(按 type 判别联合),
  github-trending 的命名图片用对象 {socialPreview,starHistory}。
- duration 仅表音频时长;画面时长由模板计算(允许无声 padding/hold)。
- publish 从文档移出,作为文字模块的独立产物(不混入内容契约)。

三模块:
- text: collect→generate→assemble 三层;github-trending 逐仓抓 README + 类型化 repos
  权威覆盖;产出 VideoDocument + publish。
- audio: TTS 合成 + wordsToCaptions 字幕分段回填 caption[]。
- renderer: assets/compose/render/export/oss 迁入;按 audioFilenameFor 命名下载;
  逐平台渲染导出上传;图片从 extension.images(对象)解析。

编排器 packages/core:瘦身 runDocument(链三模块 + 飞书通知 + 清理),删除旧 7 阶段。

模板:5 模板适配 RenderScene(kind/cardList/caption/extension);SubtitleBar 读
预计算 caption[];画面帧由 Root.tsx sceneDurationFrames 计算(不再由渲染器折帧)。

服务化:web 进程内调 runDocument(@pipeline/core 等重依赖懒加载 + webpack externals
规避 esbuild),调度器/HTTP 沿用;CLI 进程内调 runDocument。

部署/工具:Dockerfile 含 3 新包;turbo outputs 加 .next/**;.dockerignore 排除 .turbo;
CLAUDE.md 重写;新增 scripts/verify-refactor.sh 与 dump-video-document.mjs。

全量 pnpm build / typecheck 通过。

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 1 miesiąc temu
rodzic
commit
37b596c074
70 zmienionych plików z 2844 dodań i 1976 usunięć
  1. 1 0
      .dockerignore
  2. 126 67
      CLAUDE.md
  3. 13 1
      Dockerfile
  4. 41 80
      apps/cli/src/commands/render.ts
  5. 42 17
      apps/web/next.config.mjs
  6. 1 0
      apps/web/package.json
  7. 7 1
      apps/web/src/app/api/collect/route.ts
  8. 2 1
      apps/web/src/app/api/collect/sources/route.ts
  9. 80 135
      apps/web/src/lib/run-render.ts
  10. 3 0
      config/default.yaml
  11. 64 0
      docs/项目解耦
  12. 26 0
      packages/audio/package.json
  13. 61 0
      packages/audio/src/index.ts
  14. 49 0
      packages/audio/src/segment.ts
  15. 100 0
      packages/audio/src/synthesize.ts
  16. 16 0
      packages/audio/src/types.ts
  17. 13 0
      packages/audio/tsconfig.json
  18. 16 21
      packages/collect/src/collectors/github-daily.ts
  19. 11 16
      packages/collect/src/collectors/github-repo.ts
  20. 62 20
      packages/collect/src/collectors/github-trending.ts
  21. 2 2
      packages/collect/src/types.ts
  22. 3 8
      packages/core/package.json
  23. 184 0
      packages/core/src/document.ts
  24. 7 3
      packages/core/src/index.ts
  25. 0 275
      packages/core/src/pipeline.ts
  26. 34 88
      packages/core/src/publish/index.ts
  27. 0 95
      packages/core/src/stages/compose.ts
  28. 0 109
      packages/core/src/stages/export.ts
  29. 0 393
      packages/core/src/stages/parse.ts
  30. 0 130
      packages/core/src/stages/render.ts
  31. 0 167
      packages/core/src/stages/tts.ts
  32. 3 2
      packages/core/tsconfig.json
  33. 31 0
      packages/renderer/package.json
  34. 29 79
      packages/renderer/src/assets.ts
  35. 95 0
      packages/renderer/src/compose.ts
  36. 98 0
      packages/renderer/src/export-file.ts
  37. 58 0
      packages/renderer/src/github-images.ts
  38. 76 0
      packages/renderer/src/index.ts
  39. 4 39
      packages/renderer/src/oss.ts
  40. 116 0
      packages/renderer/src/render.ts
  41. 47 0
      packages/renderer/src/types.ts
  42. 2 2
      packages/renderer/src/types/ali-oss.d.ts
  43. 13 0
      packages/renderer/tsconfig.json
  44. 1 1
      packages/shared/src/index.ts
  45. 1 6
      packages/shared/src/llm/prompts/parse-text.ts
  46. 175 0
      packages/shared/src/types/document.ts
  47. 33 0
      packages/shared/src/types/index.ts
  48. 82 0
      packages/shared/src/types/render.ts
  49. 0 1
      packages/shared/src/types/scene.ts
  50. 10 0
      packages/shared/src/utils/audio-filename.ts
  51. 1 0
      packages/shared/src/utils/index.ts
  52. 98 110
      packages/templates/src/Root.tsx
  53. 27 52
      packages/templates/src/base/components/subtitle-bar.tsx
  54. 16 14
      packages/templates/src/github-trending/index.tsx
  55. 9 8
      packages/templates/src/knowledge/index.tsx
  56. 10 9
      packages/templates/src/marketing/index.tsx
  57. 13 12
      packages/templates/src/news/index.tsx
  58. 9 8
      packages/templates/src/opinion/index.tsx
  59. 26 0
      packages/text/package.json
  60. 200 0
      packages/text/src/assemble.ts
  61. 22 0
      packages/text/src/collect.ts
  62. 80 0
      packages/text/src/generate.ts
  63. 56 0
      packages/text/src/index.ts
  64. 158 0
      packages/text/src/postprocess.ts
  65. 26 0
      packages/text/src/types.ts
  66. 13 0
      packages/text/tsconfig.json
  67. 54 3
      pnpm-lock.yaml
  68. 103 0
      scripts/dump-video-document.mjs
  69. 84 0
      scripts/verify-refactor.sh
  70. 1 1
      turbo.json

+ 1 - 0
.dockerignore

@@ -5,5 +5,6 @@ output
 .git
 .git
 .env
 .env
 .claude
 .claude
+.turbo
 *.md
 *.md
 !packages/*/README.md
 !packages/*/README.md

+ 126 - 67
CLAUDE.md

@@ -4,7 +4,9 @@
 
 
 ## 项目概述
 ## 项目概述
 
 
-文本转视频生成流水线。接收描述视频结构(封面、场景、结束语)的 JSON 输入,输出带 TTS 旁白、字幕和模板视觉样式的 MP4 视频。
+文本转视频生成流水线。核心是一条**解耦的三模块管线**:AI 文字 → AI 音频 → Remotion 调度器,三者只通过**一个固定 JSON 类型 `VideoDocument`** 通信(每个模块用 Zod 校验输入、不满足即报错)。核心功能以**服务方式常驻运行**(Next.js 服务进程内串联三模块、原生定时执行),CLI 与 WebUI 是它的薄客户端。
+
+> 历史背景:早期是一条 `runPipeline` 串 7 阶段、Next.js 通过 `spawn` CLI 子进程跑渲染。本次重构把它拆成三模块 + 单一 JSON 契约,并把核心逻辑从 CLI 进程内化到服务进程内(不再 spawn)。
 
 
 ## 常用命令
 ## 常用命令
 
 
@@ -13,10 +15,10 @@ pnpm build          # 构建所有包 (turbo)
 pnpm typecheck      # 类型检查所有包
 pnpm typecheck      # 类型检查所有包
 pnpm lint           # Lint 所有包
 pnpm lint           # Lint 所有包
 
 
-# 运行视频渲染
-node apps/cli/dist/index.js render test/fixtures/sample-knowledge.json -t knowledge -p bilibili
+# 渲染(CLI 现在进程内直跑核心 runDocument)
+node apps/cli/dist/index.js render -t github-trending -p bilibili --source github-trending
 
 
-# Web UI 开发服务器
+# Web 服务(核心服务宿主)开发
 cd apps/web && pnpm dev
 cd apps/web && pnpm dev
 ```
 ```
 
 
@@ -24,105 +26,162 @@ cd apps/web && pnpm dev
 
 
 ## 架构
 ## 架构
 
 
-pnpm workspaces + Turborepo 的 monorepo 结构。
+pnpm workspaces + Turborepo 的 monorepo。
 
 
 ```
 ```
-apps/cli/          CLI 入口 (Commander)
-apps/web/          Next.js 15 WebUI + API 路由
-packages/shared/   Zod schema、类型定义、常量、LLM 客户端
-packages/core/     流水线编排与 6 个阶段
-packages/tts/      TTS provider 注册表 + 4 个 provider
-packages/templates/ 5 个模板的 Remotion React 组件
+apps/cli/           CLI(薄客户端:进程内调用 core 的 runDocument)
+apps/web/           Next.js 15 = 核心服务宿主(HTTP API + 调度器 + 任务存储 + health),进程内调 core
+packages/shared/    Zod schema、类型、常量、LLM client、VideoDocument 契约、工具
+packages/collect/   数据源(DataSource 注册表 + 3 个 github 采集器),与文字逻辑解耦
+packages/text/      【AI 文字模块】数据源协调 + 多步 LLM → VideoDocument
+packages/audio/     【AI 音频模块】TTS + 字幕分段 → 回填 VideoDocument
+packages/renderer/  【Remotion 调度器】资源下载/帧/渲染/导出/OSS → ExportFile[]
+packages/tts/       TTS provider 注册表 + 4 个 provider(被 audio 调用)
+packages/templates/ 5 个模板的 Remotion 组件(读 RenderProps/RenderScene)
+packages/core/      瘦编排器 runDocument:链 text→audio→renderer + 飞书通知 + 清理
 ```
 ```
 
 
-### 流水线数据流
+### 三模块数据流(核心)
 
 
-输入始终是**结构化 JSON** (`VideoInputSchema`)。流水线在 `packages/core/src/stages/` 中依次执行 6 个阶段:
+每个模块都是**纯函数 over JSON、无状态**(并发友好)。模块间只传 `VideoDocument`:
 
 
-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` 自动建子目录;同时写一份同名 `.yaml` **发布清单**到 MP4 旁边(标题/描述/标签来自 LLM 产出的 `publish` 块;分区 tid/category 等平台特定字段来自 `config.publishMeta[平台][模板]`)。写清单是 best-effort,失败只告警不中断渲染
-7. **publish**(可选,按需开启)— 渲染成功后上传到阿里云 OSS 并推送飞书 webhook:上传成功推送 OSS 资源链接,生成/上传失败推送失败信息。生成失败(未产出文件)也会推送失败信息。未配置 OSS/飞书时跳过
+```
+input(template, source/text, platforms, ttsProvider)
+  → text.generateDocument()     → VideoDocument(caption_origin/card_list/meta 全齐,无音频)
+  → audio.generateAudio(doc)    → VideoDocument(+caption[] +caption_audio_file_url +duration 音频时长)
+  → renderer.renderDocument(doc, {platforms}) → 逐平台下载音频/图片(按 audioFilenameFor 命名)
+                                              → Remotion 渲染(模板算画面帧)→ 导出 → OSS 上传 → ExportFile[]
+  → core.runDocument() 串联三步 + 飞书通知(成功/失败) + TTL 清理
+```
 
 
-核心类型链:`VideoInput` → `ParsedContent` → `ComposedProject` → Remotion props。
+核心类型链:`VideoDocument`(模块间契约)→ `RenderProps`/`RenderScene`(renderer↔template 契约)→ Remotion props。
 
 
-### Remotion 约束
+### VideoDocument —— 单一 JSON 契约(v2.0)
 
 
-Remotion 通过其 webpack dev server 提供所有静态资源。**组件中绝不能使用文件系统绝对路径** — 必须使用 `staticFile(filename)`。render 阶段会将所有资源(音频、图片、背景图)复制到统一的 `publicDir`,通过 props 传递文件名。
+定义在 `packages/shared/src/types/document.ts`。三模块都用它做输入校验:
 
 
-音频按场景独立播放:每个 `<Sequence>` 包含自己的 `<Audio>` 组件,不使用全局音频轨道。
+```
+VideoDocument { version:"2.0", type:"ppt", template, config?, meta?, data: VideoSegment[] }
+  meta   → 视频级(渲染用):title(封面刊头)/subtitle(日期)/summary/trendSummary
+```
+**`publish`(发布元信息:title/description/tags)不在 VideoDocument 里**——它是**文字模块的独立产物**(`generateDocument` 返回 `{doc, publish}`),只用于导出阶段写 MP4 旁边的 `.yaml` 发布清单,不渲染进视频。这样 `VideoDocument` 保持纯"视频内容",不混入分发关注点。编排器把 `publish` 单独透传给渲染器的导出步骤。
+  config → themeColor/bgmFileUrl(预留)/globalStyle/channelName
+VideoSegment { id, kind("cover"|"content"|"outro"), title, desc, caption_origin,
+  caption?: [{text,duration}],          // 音频模块回填
+  caption_audio_file_url?,              // 音频模块回填(本地路径或 URL)
+  card_list?: [{kind,desc}],            // 屏幕卡片(原 keyframes)
+  menu?, images?(path|url|query 通用),  // 仅通用图片来源;模板专属图片由 extension 派生
+  extension?,                           // 模板专属数据(见下),通用契约不含模板字段
+  layoutHint?, speed?, duration(音频时长)? }
+```
+**`duration` 仅表示音频时长**(音频模块回填,无音频=0)。**画面时长由模板计算**(`Root.tsx` 的 `sceneDurationFrames`,默认=音频时长、静音段兜底;模板可加无声 padding/hold/特殊处理,故画面可与音频不同步)。渲染器不再折帧——帧时间轴由 Remotion Composition(`calculateMetadata`)+ 模板决定。
+**模板专属数据走 `extension`**(`SegmentExtension`,按 `type` 判别的联合):github-trending 的 `{type:"github-trending", repo, highlights, intro, review, images?}`,其中 `images` 是**对象** `{socialPreview?:"owner/name", starHistory?:url}`(模板显式声明、进 JSON;命名图片用对象不用数组)。通用 `images` 只含 `path/url/query`——**不再有 `repoSocialPreview` 这种模板专属字段**。渲染器 `renderer/github-images.ts` 读取 `extension.images` 解析(social preview 走爬虫、star history 走 url),按名回填为解析后 RenderImage 对象,模板按名读取(无下标耦合)。新增模板加自己的 extension 变体即可,不污染通用契约。
 
 
-### TTS Provider 体系
+**旧 → 新字段映射**:`sceneType→kind`、`narration→caption_origin`、`displayText→title`、`keyframes→card_list`、`wordTimestamps→(音频模块段化后)caption[]`、`github(输入层)→extension(契约层, 模板专属)`、`images.repoSocialPreview→渲染器从 extension 派生(不再进通用 images)`、`publish→文字模块独立产物(不进 VideoDocument)`、`trendSummary→meta.trendSummary`。音频命名共用 `audioFilenameFor(segId)`(`packages/shared/src/utils/audio-filename.ts`)。
 
 
-Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册。每个 provider 实现 `TTSProvider` 接口(`synthesize`、`listVoices`、`align`)。新增 provider 的方式:在 `providers/` 下创建文件并在 `index.ts` 中 import。
+## 模块职责
 
 
-### 模板系统
+### 1. 文字模块 `packages/text`
 
 
-5 个模板(news、knowledge、opinion、marketing、github-trending),每个在 `packages/templates/src/<name>/index.tsx` 中有对应的场景组件。共享基础组件位于 `packages/templates/src/base/`。`Root.tsx` 按以下规则路由:
-- `sceneType === "cover"` → 内置 `CoverScene`(`github-trending` cover 渲染:深色标题卡居中靠上 + top6 仓库卡片网格[横屏 3×2 / 竖屏 2×3,每卡:名称/语言/今日 +N 涨星/highlights 一句话] + trendSummary 副标题;另在进度条上方渲染 `ChapterToc` 章节目录,当前章节高亮)
-- `sceneType === "outro"` → 内置 `OutroScene`
-- 其他 content 类型 → `SCENE_MAP[template]` 对应模板组件
+`generateDocument(input) → VideoDocument`,三层分离(**数据源与 AI 文字逻辑拆开**,为混合数据源留口子):
 
 
-`github-trending` 是当前唯一带 `isPortrait` 分支的模板,竖屏布局采用 flex column(头部取自然高度,内容 flex 填充),横屏保持绝对定位。`Section` 子组件通过 `size: "default" | "large"` 区分横竖屏字号档位,并用 `-webkit-line-clamp` 做安全截断(body 用 `flex: "0 0 auto"` 防止 shrink 导致的单行底部裁剪)。
+- **collect.ts(数据源层)**:调用 `@pipeline/collect` 取数,返回 markdown(喂 LLM)+ 类型化 `repos: RepoMeta[]`(权威元数据通道)。
+- **generate.ts(LLM 层)**:`detectInputFormat` → 已是合法 JSON 则直接用;否则调 LLM(复用 `getParsePrompt`,截断时重试一次)→ 校验 → 清洗。
+- **assemble.ts(装配层,确定性、无 LLM/无网络)**:VideoInput + repos → VideoDocument。github-trending 在此:① 内容场景按 `todayStars` 降序取 top6;② 确定性写 cover 开场口播(`大家好,今天{trendSummary}。下面进入项目详解。`,无 trendSummary 时退化);③ 用类型化 `repos` 组装每个场景的 `extension`(github-trending:repo/highlights/intro/review,数据源权威、覆盖 LLM 从注释块拷的值),**不再写通用 images**(github 图片由渲染器从 extension 派生);④ 所有 `caption_origin` 跑 `normalizeCountsForTTS`(`Nk`→中文口语)。
 
 
-`SubtitleBar`(`packages/templates/src/base/components/subtitle-bar.tsx`)接受可选 `fontSize` 和 `style` prop——竖屏场景可传 `{ bottom: 380 }` 提高字幕位置、`fontSize: 56` 放大字号;横屏不传则保持默认(`bottom: 60`、`fontSize: 40`)。
+> 文字模块的"多步"实现为 collect→generate→assemble 三层;其中"逐仓描述→全局首屏→章节口播→发布元信息"目前由**一次 LLM 调用**产出(质量优先、必要时合并)。`github-trending` 采集器现**逐仓抓取 README**(按 todayStars 取 top8)做描述扎实度。
+> 旧 `<!-- repo-meta -->`/`<!-- repo-images -->` 注释协议仍由采集器输出(让既有 prompt 不变),但已被类型化 `repos` 覆盖层取代为非关键冗余,可在后续清理中移除。
 
 
-## 输出目录与发布
+### 2. 音频模块 `packages/audio`
 
 
-### 统一输出目录
+`generateAudio(doc, opts) → VideoDocument`:遍历 `doc.data`,读 `caption_origin` 调 `@pipeline/tts` 合成,再用 `wordsToCaptions`(移植自 `SubtitleBar.groupIntoSegments`)一次性把词时间戳拆成 `caption[{text,duration}]`,连同 `caption_audio_file_url` 与**音频时长 `duration`**(= 音频长度;无 caption_origin 的段=0)回填。`--no-tts` 走 `silentSegmentAudio`(按文本长度估算时长 + 合成均匀词时间戳)。返回**新 doc**,不改入参。
 
 
-CLI / HTTP / WebUI 的视频都写入**同一个**输出目录,由 `packages/shared` 的 `resolveOutputDir()` 统一解析(相对路径相对 monorepo 根,CLI 与 `apps/web` 因此落地到同一物理目录)。解析优先级:`OUTPUT_DIR` 环境变量 > `config.output.dir` > `./output`。
+### 3. 渲染模块 `packages/renderer`
 
 
-目录布局:`{outputDir}/{模板}/{ISO日期 YYYY-MM-DD}/{模板}-{平台}-{jobId前8位}.mp4`,在 `export.ts` 中由 `isoDateString()` 生成日期子目录。日期取**配置时区**(默认 `Asia/Shanghai`,可用 `TIMEZONE` 覆盖)而非 UTC,与首屏显示日期保持一致,避免 UTC 容器跨天错位。
+`renderDocument(doc, {platforms,workDir,jobId,outputDir,assetsRoot,inputDir,templatesEntry,...}) → ExportFile[]`,逐平台:
 
 
-### 缓存自动清理
+- `assets.ts` 解析图片(本地 path > repoSocialPreview > url > query;socialPreview 调爬虫解 base64 PNG)。github 图片走 `extension.images`(**对象** `{socialPreview,starHistory}`,非数组)→ `github-images.ts` 的 `segmentImageRefs` 展开为可解析引用、`resolveGithubImages` 再按名回填为解析后 RenderImage 对象给模板按名读取。
+- `compose.ts`(`composeRenderProps`)只做内容投影 + 资源文件名解析(`audioFilenameFor`)+ 透传音频 `duration`,**不算帧/尺寸**——帧时间轴由模板(Remotion Composition)决定。
+- `render.ts` `preparePublicDir`(复制音频/背景/图/字体进 publicDir)+ `bundle` + `renderMedia`;尺寸/总帧数从解析出的 Composition 读取(回传给 export)。
+- `render.ts` `preparePublicDir`(复制音频/背景/图/字体进 publicDir)+ `bundle` + `renderMedia`。
+- `export-file.ts` 导出 `{template}/{日期}/{名}.mp4` + 写 `.yaml` 发布清单。
+- `oss.ts` 上传 OSS 回填 `ossUrl`(失败只告警)。
 
 
-每次 `runPipeline` 启动时按 `config.output.retentionDays`(默认 30,可用 `OUTPUT_RETENTION_DAYS` 覆盖,设 0 禁用)做一次机会性扫描,删除 mtime 超过 TTL 的文件及随之变空的目录(`packages/core/src/cleanup.ts`)。该清理是 best-effort,永不抛错、不会中断渲染。
+平台多路复用在本模块(Document 平台无关、音频时长用秒;画面帧由模板在渲染期算)。
 
 
-### 发布(OSS + 飞书)
+### 编排器 `packages/core`
 
 
-publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分片上传、`feishu.ts` 带可选 HMAC 签名)。`resolvePublishConfig()` 把 `config/default.yaml` 的 `oss`/`feishu` 与环境变量合并;只要 OSS 或飞书任一可用就启用。行为:渲染成功→上传成功推送 OSS 链接;上传失败推送"已生成但上传失败";渲染本身失败推送"生成失败"。本地调试可用 CLI `--no-publish` 跳过,或 `pipelineConfig.skipPublish`。OSS 上传的 URL 会回填到 `ExportFile.ossUrl` 并由 CLI 打印 `oss: <url>`(Web 解析后存入 `JobData.ossUrls`)。
+`runDocument(input, config) → DocumentRunResult`:链 text→audio→renderer,飞书通知(成功推送 OSS 链接/失败推送错误),TTL 清理。**无状态**,服务/CLI 都直接进程内调它。
 
 
-> publish 仅在 CLI 进程(`runPipeline`)中执行;WebUI 通过 spawn CLI 复用该流程,不直接依赖 `@pipeline/core`/`ali-oss`,故 `ali-oss` 只出现在 core 的依赖里。
+## 核心服务化(重要)
 
 
-## 配置
+**核心逻辑在 Next.js 服务进程内运行,不再 spawn CLI**:
 
 
-- `.env`(monorepo 根目录)— 所有 provider 的 API Key(CLI 启动时加载,Next.js 通过 `next.config.ts` 加载)
-- `config/default.yaml` — TTS provider、模型、模板配色、输出设置、OSS/飞书非敏感配置
-- CLI 按以下顺序读取配置:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`
+- `apps/web/src/lib/run-render.ts` 的 `startRenderJob` 直接 `await runDocument(...)`(进程内),任务进 jobs 列表、走 OSS/飞书。`POST /api/render` 与调度器都走它。
+- **Next.js webpack 无法打包 `@remotion/bundler → esbuild`**,所以 `apps/web/next.config.mjs` 把所有服务端 workspace 包(`@pipeline/{core,text,audio,renderer,tts,collect}`)在 server 端强制 external(`webpack` 配置里往 `config.externals` 数组**追加**一个判别函数;`serverExternalPackages` 单独不够——Next 会顺着 workspace 软链深入 `@pipeline/renderer` 的真实路径去 bundle)。`@pipeline/shared` 仍 transpile(客户端页面要用 `TEMPLATE_TYPES`)。
+  - **重依赖必须懒加载**:web 服务端代码里 `@pipeline/core`、`@pipeline/collect` 用**动态 `await import()`**(在 handler 内部,`apps/web/src/lib/run-render.ts`、`app/api/collect/route.ts`、`app/api/collect/sources/route.ts`),**绝不在模块顶层静态 import**。否则 Next 构建期"收集页面数据"会尝试求值这些 externalized 的 ESM 包(CJS require ESM-only 的 exports 会失败)。类型用 `import type`(编译期擦除,不产生运行时 import)。
+  - **turbo 配置**:`turbo.json` 的 `build.outputs` 必须含 `.next/**`(排除 `.next/cache/**`),否则 turbo 缓存命中时会跳过 `next build`、不产出 `.next`。`.dockerignore` 排除 `.turbo`,避免本地 turbo 缓存泄入镜像构建。
+- 调度器(`apps/web/src/lib/scheduler.ts` + `instrumentation.ts`)沿用 node-cron,到点调 `startRenderJob`。
+- CLI(`apps/cli/src/commands/render.ts`)也进程内调 `runDocument`(薄客户端,本地无需服务在跑)。
 
 
-### 发布相关环境变量
+## Remotion 约束
 
 
-- `OUTPUT_DIR` / `OUTPUT_RETENTION_DAYS` — 统一输出目录与缓存保留天数
-- `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` 关闭容器内进程内调度器(默认开启)
+Remotion 通过其 webpack dev server 提供静态资源。**组件中绝不能用文件系统绝对路径** — 必须用 `staticFile(filename)`。渲染模块把所有资源(音频、图片、背景图、字体)复制进统一 `publicDir`,用文件名传 props。
 
 
-## 定时生成(容器内调度)
+音频按场景独立播放:每个 `<Sequence>` 含自己的 `<Audio src={staticFile(scene.audioFilename)} />`,无全局音轨。
+
+渲染期契约(`packages/shared/src/types/render.ts`):`RenderScene`(VideoSegment 的渲染投影 + `duration` 音频时长 + `audioFilename`/`backgroundAsset`/解析后的 images,**无 startFrame/endFrame**)+ `RenderProps`(无 fps/尺寸——由 Composition 提供)。`Root.tsx` 的 `RemotionScene`/`RemotionProps` 即此类型的再导出(迁移期别名)。
+
+## TTS Provider 体系
+
+Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 注册,实现 `TTSProvider` 接口(`synthesize`/`listVoices`/`align`)。音频模块用 `getProvider(name)` 取用。whisper 对齐(`alignWithWhisper`)可选(默认走 provider 的 `estimateWordTimestamps`)。
+
+## 模板系统
+
+5 个模板(news、knowledge、opinion、marketing、github-trending),每个在 `packages/templates/src/<name>/index.tsx`。`Root.tsx` 路由:`scene.kind === "cover"` → 内置 `CoverScene`;`"outro"` → 内置 `OutroScene`;其他 → `SCENE_MAP[template]`。
 
 
-容器内置**进程内调度器**:Next.js 服务启动时经 `apps/web/src/instrumentation.ts`(`register()` 钩子)启动 `apps/web/src/lib/scheduler.ts`,用 `node-cron` 按 `config/default.yaml` 的 `schedules` 段(cron + TZ,默认 Asia/Shanghai)到点 spawn CLI 渲染——复用 `/api/render` 同一链路(`apps/web/src/lib/run-render.ts` 的 `startRenderJob`),任务进 jobs 列表、走 OSS/飞书发布。单副本下进程内调度不重复执行;内置单飞(上一次未结束则跳过)。改配置后重启容器生效;`SCHEDULES` 环境变量可整体覆盖,`SCHEDULER_ENABLED=false` 关闭。详见 `docs/DEPLOYMENT.md` §4.3。
+- `github-trending` cover:深色标题卡(刊头 + 日期 + trendSummary)+ top6 仓库卡片网格(由 content 场景派生,按 todayStars 排序),进度条上方 `ChapterToc`(高亮当前章节)。是唯一带 `isPortrait` 分支的模板。
+- `SubtitleBar`(`base/components/subtitle-bar.tsx`)现在**读预计算的 `caption[]`**(按累计时长找当前字幕),不再运行期分词。可选 `fontSize`/`style`(竖屏传 `{bottom:380}`/`fontSize:56`)。
+- 模板字段:读 `scene.title`(原 displayText)、`scene.captionOrigin`、`scene.cardList`(原 keyframes,`card.kind`/`card.desc`)、`scene.caption`、`scene.extension`(模板专属,如 github-trending 的 repo/highlights)、`scene.images[].filename`。
 
 
-## 核心 Schema
+**新增场景字段时**:`VideoSegment`(document.ts)+ `RenderScene`(render.ts)+ 模板组件三处同步,并在 renderer 的 `composeRenderProps` 显式透传(不是 `...seg` 展开)。
 
 
-全部在 `packages/shared/src/types/` 中用 Zod 定义:
+## 输出目录与发布
+
+### 统一输出目录
+
+CLI / HTTP / WebUI 写入**同一个**输出目录,由 `packages/shared` 的 `resolveOutputDir()` 解析(`OUTPUT_DIR` > `config.output.dir` > `./output`,相对路径相对 monorepo 根)。布局:`{outputDir}/{模板}/{ISO日期}/{模板}-{平台}-{jobId前8位}.mp4`,日期取**配置时区**(默认 `Asia/Shanghai`,`TIMEZONE` 覆盖)。
+
+### 缓存自动清理
+
+每次 `runDocument` 启动时按 `config.output.retentionDays`(默认 30,`OUTPUT_RETENTION_DAYS` 覆盖,0 禁用)做机会性扫描,删过期文件及随之变空的目录(`packages/core/src/cleanup.ts`)。best-effort,永不抛错。
+
+### 发布(OSS 在 renderer,飞书在 core)
 
 
-- `VideoInputSchema` — 用户输入格式(title、subtitle、cover、scenes[]、outro、globalStyle;github-trending 另有可选 `trendSummary`,由 LLM 产出。`coverTags` 字段保留但已停用)
-- `SceneSchema` — 内部场景,含 `sceneType: "cover" | "content" | "summary" | "outro"`、可选 `github`(GithubSceneData,`repo` 含 `todayStars` 今日涨星)、可选 `trendSummary`(string,仅 github-trending 的 cover 场景用。`coverTags` 保留但停用)
-- `ComposedSceneSchema` — compose 后的场景,含帧时间轴、`audioPath`、已解析的图片。字段与 `SceneSchema` 一一对应
-- `PipelineInputSchema` — 顶层流水线输入(text、template、platform、ttsProvider)
+- **OSS 上传**在渲染模块(`packages/renderer/src/oss.ts`,ali-oss 分片上传),`ossUrl` 回填到 `ExportFile`。
+- **飞书通知**在 core 编排器(`packages/core/src/publish/`:`notifyFeishuSuccess`/`notifyGenerationFailure`,可选 HMAC 签名 + @ 成员)。渲染成功→推 OSS 链接;上传失败→推"已生成但上传失败";渲染本身失败→推"生成失败"。
+- `resolvePublishConfig()` 把 `config/default.yaml` 的 `oss`/`feishu` 与环境变量合并。`--no-publish`/`skipPublish` 跳过两者。
 
 
-**新增场景字段时三个 schema 都要同步:** `SceneSchema`(scene.ts)、`ComposedSceneSchema`(pipeline.ts)、`RemotionScene`(Root.tsx 的 interface)。还要在 `compose.ts` 手动透传(不是 `...scene` 展开),否则字段会在 compose 阶段丢失。
+## 配置
+
+- `.env`(monorepo 根)— 所有 provider 的 API Key(CLI 启动时加载,Next.js 经 `next.config.mjs` 加载)。
+- `config/default.yaml` — TTS provider、模型、模板配色、输出设置、OSS/飞书非敏感配置、`collect` 数据源 URL(github-trending 现含 `repoUrl` 用于逐仓 README)、`schedules`。
+- CLI/服务读取:`--config` → `pipeline.config.yaml` → `config/default.yaml`。
+
+### 关键环境变量
+
+`OUTPUT_DIR` / `OUTPUT_RETENTION_DAYS` / `TIMEZONE`;OSS_*;`FEISHU_WEBHOOK_URL`/`FEISHU_WEBHOOK_SECRET`/`FEISHU_AT_OPEN_IDS`;`SCHEDULES`(JSON 数组整体覆盖)/ `SCHEDULER_ENABLED`;LLM `OPENAI_API_KEY`/`OPENAI_BASE_URL`;TTS `OPENAI_TTS_*` 等。
 
 
-**视频级(非场景级)字段另走一条链:** 加在 `VideoInputSchema` + `ParsedContentSchema` + `ComposedProjectSchema`(都是项目顶层,不进 `ComposedSceneSchema`/`RemotionScene`),并在 `parse.ts` 的 `result` 与 `compose.ts` 的返回项目里显式透传。`publish`(发布清单用的 标题/描述/标签)就是这种——不渲染进视频,只由 export 阶段写成 MP4 旁边的 `.yaml`。
+## 定时生成(容器内调度)
+
+容器内置**进程内调度器**:Next.js 服务启动时经 `apps/web/src/instrumentation.ts` 启动 `scheduler.ts`,node-cron 按 `config/default.yaml` 的 `schedules`(cron + TZ)到点调 `startRenderJob` → 进程内 `runDocument`。单副本下不重复执行,内置单飞。`SCHEDULES` 覆盖、`SCHEDULER_ENABLED=false` 关闭。详见 `docs/DEPLOYMENT.md`。
 
 
 ## LLM 与 TTS 提示
 ## 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` 按模板拼装的(`大家好,今天{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` 跳过(生成静音视频)
+- `LLMClient`(`packages/shared/src/llm/client.ts`)`max_tokens:16384`。大 trending 列表可能撑爆被截断 → JSON 不全;文字模块对解析失败**重试一次**(追加精简指令),错误带 `finish_reason`。
+- github-trending 的 cover 口播由 `assemble.ts` 按模板拼装(不交给 LLM,不含日期/数量);`trendSummary` 由 LLM 产出(**既朗读又作封面副标题**)。内容场景口播只念**项目短名**。所有口播经 `normalizeCountsForTTS` 把 `Nk` 转中文口语。
+- TTS 按场景独立合成,受 provider 配额限制;`--no-tts` 跳过(静音视频)。
+
+## 容器化注意
+
+Dockerfile 三阶段。**runtime 阶段已包含全部 9 个 workspace 包**(package.json + `@pipeline/*` 软链 + dist),web 经 webpack externals 运行时 require `@pipeline/core`(及 renderer→remotion/ali-oss),故 prod install 会把这些依赖拉进 node_modules。Remotion Chrome 仍在构建期预置。容器实际构建需在真实环境验证(本仓库无法离线构建镜像)。

+ 13 - 1
Dockerfile

@@ -23,6 +23,9 @@ COPY packages/core/package.json packages/core/
 COPY packages/tts/package.json packages/tts/
 COPY packages/tts/package.json packages/tts/
 COPY packages/templates/package.json packages/templates/
 COPY packages/templates/package.json packages/templates/
 COPY packages/collect/package.json packages/collect/
 COPY packages/collect/package.json packages/collect/
+COPY packages/text/package.json packages/text/
+COPY packages/audio/package.json packages/audio/
+COPY packages/renderer/package.json packages/renderer/
 
 
 RUN pnpm config set registry https://registry.npmmirror.com && \
 RUN pnpm config set registry https://registry.npmmirror.com && \
     pnpm install --frozen-lockfile
     pnpm install --frozen-lockfile
@@ -66,6 +69,9 @@ COPY --from=build /app/packages/core/package.json packages/core/
 COPY --from=build /app/packages/tts/package.json packages/tts/
 COPY --from=build /app/packages/tts/package.json packages/tts/
 COPY --from=build /app/packages/templates/package.json packages/templates/
 COPY --from=build /app/packages/templates/package.json packages/templates/
 COPY --from=build /app/packages/collect/package.json packages/collect/
 COPY --from=build /app/packages/collect/package.json packages/collect/
+COPY --from=build /app/packages/text/package.json packages/text/
+COPY --from=build /app/packages/audio/package.json packages/audio/
+COPY --from=build /app/packages/renderer/package.json packages/renderer/
 
 
 # Install production deps with hoisted layout (everything flat at root node_modules)
 # Install production deps with hoisted layout (everything flat at root node_modules)
 RUN pnpm config set registry https://registry.npmmirror.com && \
 RUN pnpm config set registry https://registry.npmmirror.com && \
@@ -77,7 +83,10 @@ RUN mkdir -p node_modules/@pipeline && \
     ln -s ../../packages/core node_modules/@pipeline/core && \
     ln -s ../../packages/core node_modules/@pipeline/core && \
     ln -s ../../packages/tts node_modules/@pipeline/tts && \
     ln -s ../../packages/tts node_modules/@pipeline/tts && \
     ln -s ../../packages/templates node_modules/@pipeline/templates && \
     ln -s ../../packages/templates node_modules/@pipeline/templates && \
-    ln -s ../../packages/collect node_modules/@pipeline/collect
+    ln -s ../../packages/collect node_modules/@pipeline/collect && \
+    ln -s ../../packages/text node_modules/@pipeline/text && \
+    ln -s ../../packages/audio node_modules/@pipeline/audio && \
+    ln -s ../../packages/renderer node_modules/@pipeline/renderer
 
 
 # Copy build artifacts (dist, .next, template src for Remotion entry, etc.)
 # 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/cli/dist apps/cli/dist/
@@ -91,6 +100,9 @@ COPY --from=build /app/packages/tts/scripts packages/tts/scripts/
 COPY --from=build /app/packages/templates/dist packages/templates/dist/
 COPY --from=build /app/packages/templates/dist packages/templates/dist/
 COPY --from=build /app/packages/templates/src packages/templates/src/
 COPY --from=build /app/packages/templates/src packages/templates/src/
 COPY --from=build /app/packages/collect/dist packages/collect/dist/
 COPY --from=build /app/packages/collect/dist packages/collect/dist/
+COPY --from=build /app/packages/text/dist packages/text/dist/
+COPY --from=build /app/packages/audio/dist packages/audio/dist/
+COPY --from=build /app/packages/renderer/dist packages/renderer/dist/
 COPY --from=build /app/assets assets/
 COPY --from=build /app/assets assets/
 COPY --from=build /app/config config/
 COPY --from=build /app/config config/
 
 

+ 41 - 80
apps/cli/src/commands/render.ts

@@ -1,8 +1,7 @@
 import { Command } from "commander";
 import { Command } from "commander";
-import { runPipeline, resolvePublishConfig, type PipelineConfig } from "@pipeline/core";
+import { runDocument, resolvePublishConfig, type DocumentRunConfig } from "@pipeline/core";
 import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
 import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
 import { resolveOutputDir } from "@pipeline/shared/node";
 import { resolveOutputDir } from "@pipeline/shared/node";
-import { getCollector, listCollectorNames } from "@pipeline/collect";
 import { readFileSync, existsSync } from "node:fs";
 import { readFileSync, existsSync } from "node:fs";
 import { resolve, dirname } from "node:path";
 import { resolve, dirname } from "node:path";
 import { parse as parseYaml } from "yaml";
 import { parse as parseYaml } from "yaml";
@@ -66,27 +65,17 @@ export const renderCommand = new Command("render")
     const config = loadConfig(opts.config);
     const config = loadConfig(opts.config);
 
 
     // Collect data from source or read from file
     // Collect data from source or read from file
-    let text: string;
-    let inputDir: string;
+    let text: string | undefined;
+    let sourceArgs: Record<string, string> | undefined;
 
 
     if (opts.source) {
     if (opts.source) {
-      const available = listCollectorNames();
-      if (!available.includes(opts.source)) {
-        console.error(`Error: Unknown source "${opts.source}". Available: ${available.join(", ")}`);
-        process.exit(1);
-      }
-      const collectorConfig = config?.collect?.[opts.source];
-      const collector = getCollector(opts.source, collectorConfig);
-      const args: Record<string, string> = {};
-      if (opts.sourceOwner) args.owner = opts.sourceOwner;
-      if (opts.sourceRepo) args.repo = opts.sourceRepo;
-
-      console.log(`Collecting from ${opts.source}...`);
-      const result = await collector.collect(
-        Object.keys(args).length > 0 ? { args } : undefined
-      );
-      text = result.type === "json" ? JSON.stringify(result.content) : result.content;
-      inputDir = resolve(process.cwd());
+      // Data-source collection now happens inside the text module (generateDocument),
+      // which reads config.collect[source]. Just pass the source name + args.
+      sourceArgs = {};
+      if (opts.sourceOwner) sourceArgs.owner = opts.sourceOwner;
+      if (opts.sourceRepo) sourceArgs.repo = opts.sourceRepo;
+      if (Object.keys(sourceArgs).length === 0) sourceArgs = undefined;
+      console.log(`Collecting from ${opts.source} (inside text module)...`);
     } else {
     } else {
       if (!input) {
       if (!input) {
         console.error("Error: <input> file path is required when --source is not provided");
         console.error("Error: <input> file path is required when --source is not provided");
@@ -98,7 +87,6 @@ export const renderCommand = new Command("render")
         process.exit(1);
         process.exit(1);
       }
       }
       text = readFileSync(inputPath, "utf-8");
       text = readFileSync(inputPath, "utf-8");
-      inputDir = opts.assetsDir ? resolve(opts.assetsDir) : dirname(inputPath);
     }
     }
 
 
     const providerName = opts.ttsProvider || config?.tts?.provider || "openai-tts";
     const providerName = opts.ttsProvider || config?.tts?.provider || "openai-tts";
@@ -108,6 +96,7 @@ export const renderCommand = new Command("render")
     const projectRoot = resolve(cliDir, "../../../../");
     const projectRoot = resolve(cliDir, "../../../../");
     const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
     const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
     const assetsRoot = resolve(projectRoot, "assets");
     const assetsRoot = resolve(projectRoot, "assets");
+    const inputDir = opts.assetsDir ? resolve(opts.assetsDir) : resolve(process.cwd());
 
 
     const noTts = skipTts(opts);
     const noTts = skipTts(opts);
     const noPublish = skipPublish(opts);
     const noPublish = skipPublish(opts);
@@ -122,7 +111,7 @@ export const renderCommand = new Command("render")
     );
     );
     const publish = noPublish ? undefined : resolvePublishConfig(config);
     const publish = noPublish ? undefined : resolvePublishConfig(config);
 
 
-    const pipelineConfig: PipelineConfig = {
+    const runConfig: DocumentRunConfig = {
       branding: {
       branding: {
         channelName: opts.channelName || config?.branding?.channelName || "Pipeline",
         channelName: opts.channelName || config?.branding?.channelName || "Pipeline",
       },
       },
@@ -137,96 +126,68 @@ export const renderCommand = new Command("render")
         model: opts.ttsModel || providerConfig?.model,
         model: opts.ttsModel || providerConfig?.model,
         format: opts.ttsFormat || config?.tts?.format || "mp3",
         format: opts.ttsFormat || config?.tts?.format || "mp3",
         speed: opts.ttsSpeed || config?.tts?.speed || 1.0,
         speed: opts.ttsSpeed || config?.tts?.speed || 1.0,
+        alignment: buildAlignmentConfig(opts, config),
       },
       },
-      output: {
-        dir: outputDir,
-        retentionDays,
-      },
+      output: { dir: outputDir, retentionDays },
       publish,
       publish,
       publishMeta: config?.publishMeta,
       publishMeta: config?.publishMeta,
-      skipPublish: noPublish,
+      collect: config?.collect,
       assets: { root: assetsRoot, inputDir },
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },
       templates: { entryPoint: templatesEntry },
-      skipTts: noTts,
-      skipLlm: opts.skipLlm,
-      alignment: buildAlignmentConfig(opts, config),
     };
     };
 
 
-    const stages = ["parse", "tts", "assets", "compose", "render", "export", "publish"];
+    const stages = ["text", "audio", "render"];
 
 
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
-    console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
+    console.log(`Input: ${(text ?? "(source)").length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
 
 
     // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
     // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
     if (noPublish) {
     if (noPublish) {
       console.log("Publish: skipped (--no-publish)");
       console.log("Publish: skipped (--no-publish)");
     } else if (publish) {
     } else if (publish) {
       console.log(`Publish: oss=${publish.oss ? "on" : "off"} feishu=${publish.feishu ? "on" : "off"}`);
       console.log(`Publish: oss=${publish.oss ? "on" : "off"} feishu=${publish.feishu ? "on" : "off"}`);
-      if (!publish.oss) {
-        const partial = ["OSS_BUCKET", "OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_REGION", "OSS_ENDPOINT", "OSS_PATH"]
-          .some((k) => process.env[k]);
-        if (partial) {
-          console.log("  (OSS off: set OSS_REGION + OSS_BUCKET + OSS_ACCESS_KEY_ID + OSS_ACCESS_KEY_SECRET to enable upload)");
-        }
-      }
     } else {
     } else {
       console.log("Publish: skipped (no OSS/Feishu configured)");
       console.log("Publish: skipped (no OSS/Feishu configured)");
     }
     }
 
 
-    const job = await runPipeline(
+    const result = await runDocument(
       {
       {
         text,
         text,
+        source: opts.source,
+        sourceArgs,
         template: template as any,
         template: template as any,
         platforms: platforms as any,
         platforms: platforms as any,
         ttsProvider: providerName as any,
         ttsProvider: providerName as any,
-        voiceId: pipelineConfig.tts.voiceId,
-        outputPath: outputDir,
-        source: opts.source,
+        voiceId: runConfig.tts.voiceId,
+        skipTts: noTts,
+        skipLlm: opts.skipLlm,
+        skipPublish: noPublish,
       },
       },
-      pipelineConfig,
+      runConfig,
       {
       {
-        onStageStart: (stage) => {
-          const [name, plat] = stage.split(":");
-          const platformLabel = plat ? ` [${plat}]` : "";
+        onStage: (stage) => {
+          const stepNum = stages.indexOf(stage) + 1;
           const labels: Record<string, string> = {
           const labels: Record<string, string> = {
-            parse: opts.skipLlm ? "Parsing JSON input" : "Parsing input (AI-assisted if needed)",
-            tts: noTts ? "Generating silent audio" : `Generating narration${pipelineConfig.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`,
-            assets: "Resolving assets",
-            compose: "Composing scenes",
-            render: "Rendering video",
-            export: "Exporting",
-            publish: "Publishing (OSS + Feishu)",
+            text: opts.skipLlm ? "Generating document (JSON input)" : "Generating document (AI text)",
+            audio: noTts ? "Generating silent audio" : `Generating narration${runConfig.tts.alignment?.provider === "whisper" ? " (whisper alignment)" : ""}`,
+            render: "Rendering video (Remotion)",
           };
           };
-          const stepNum = stages.indexOf(name as string) + 1;
-          console.log(`  [${stepNum}/${stages.length}] ${labels[name] || name}${platformLabel}...`);
-        },
-        onStageComplete: (stage) => {
-          const [name, plat] = stage.split(":");
-          const platformLabel = plat ? ` [${plat}]` : "";
-          const stepNum = stages.indexOf(name as string) + 1;
-          console.log(`  [${stepNum}/${stages.length}] ${name}${platformLabel} done`);
-        },
-        onError: (stage, error) => {
-          console.error(`\n  Error in ${stage}: ${error.message}`);
-          if (error.message.includes("API_KEY") || error.message.includes("apiKey")) {
-            console.error("\n  Hint: Use --no-tts to skip TTS:");
-            console.error("        pipeline render <file> -t <template> --no-tts\n");
-          }
+          console.log(`  [${stepNum}/${stages.length}] ${labels[stage] || stage}...`);
         },
         },
       }
       }
     );
     );
 
 
-    if (job.status === "completed" && job.exported) {
-      console.log(`\nCompleted! Job ID: ${job.id}`);
-      for (const file of job.exported.files) {
+    if (result.status === "completed" && result.files.length > 0) {
+      console.log(`\nCompleted! Job ID: ${result.jobId}`);
+      for (const file of result.files) {
         console.log(`  -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
         console.log(`  -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
         if (file.ossUrl) console.log(`  oss: ${file.ossUrl}`);
         if (file.ossUrl) console.log(`  oss: ${file.ossUrl}`);
       }
       }
-      if (job.publishError) {
-        console.error(`\nPublish warning: ${job.publishError}`);
+      if (result.publishError) {
+        console.error(`\nPublish warning: ${result.publishError}`);
       }
       }
     } else {
     } else {
-      console.error(`\nFailed: ${job.error}`);
+      console.error(`\nFailed: ${result.error}`);
       process.exit(1);
       process.exit(1);
     }
     }
   });
   });
@@ -234,13 +195,13 @@ export const renderCommand = new Command("render")
 function buildAlignmentConfig(
 function buildAlignmentConfig(
   opts: any,
   opts: any,
   config: Record<string, any> | null
   config: Record<string, any> | null
-): PipelineConfig["alignment"] {
-  const mode = opts.alignment || config?.alignment?.provider;
+): DocumentRunConfig["tts"]["alignment"] {
+  const mode = opts.alignment || config?.tts?.alignment?.provider || config?.alignment?.provider;
   if (mode !== "whisper" && mode !== "native") return undefined;
   if (mode !== "whisper" && mode !== "native") return undefined;
   return {
   return {
     provider: mode,
     provider: mode,
-    whisperModel: opts.whisperModel || config?.alignment?.whisperModel || "base",
-    language: opts.alignmentLanguage || config?.alignment?.language,
+    whisperModel: opts.whisperModel || config?.tts?.alignment?.whisperModel || config?.alignment?.whisperModel || "base",
+    language: opts.alignmentLanguage || config?.tts?.alignment?.language || config?.alignment?.language,
   };
   };
 }
 }
 
 

+ 42 - 17
apps/web/next.config.mjs

@@ -1,35 +1,60 @@
 import { existsSync, readFileSync } from "node:fs";
 import { existsSync, readFileSync } from "node:fs";
 import { resolve } from "node:path";
 import { resolve } from "node:path";
 
 
-// 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.
+// Expose the monorepo-root .env to server-side `process.env.*` reads (see note
+// in CLAUDE.md / the original config — never use nextConfig.env for secrets).
 const envPath = resolve(import.meta.dirname, "../../.env");
 const envPath = resolve(import.meta.dirname, "../../.env");
 if (existsSync(envPath)) {
 if (existsSync(envPath)) {
-  const content = readFileSync(envPath, "utf-8");
-  for (const line of content.split("\n")) {
+  for (const line of readFileSync(envPath, "utf-8").split("\n")) {
     const trimmed = line.trim();
     const trimmed = line.trim();
     if (!trimmed || trimmed.startsWith("#")) continue;
     if (!trimmed || trimmed.startsWith("#")) continue;
     const eq = trimmed.indexOf("=");
     const eq = trimmed.indexOf("=");
     if (eq < 0) continue;
     if (eq < 0) continue;
     const key = trimmed.slice(0, eq).trim();
     const key = trimmed.slice(0, eq).trim();
-    if (!key) continue;
-    const val = trimmed.slice(eq + 1).trim();
-    if (process.env[key] === undefined) process.env[key] = val;
+    if (key && process.env[key] === undefined) process.env[key] = trimmed.slice(eq + 1).trim();
   }
   }
 }
 }
 
 
+// Workspace server packages the web service imports in-process. Next's webpack
+// must NOT bundle them (they reach @remotion/bundler → esbuild, which webpack
+// can't bundle). serverExternalPackages alone does NOT stop Next from following
+// workspace symlinks and bundling these, so we also force them external on the
+// server build below. At runtime they're required from node_modules as built dist.
+const EXTERNALS = [
+  "@pipeline/core",
+  "@pipeline/text",
+  "@pipeline/audio",
+  "@pipeline/renderer",
+  "@pipeline/tts",
+  "@pipeline/collect",
+];
+
 /** @type {import('next').NextConfig} */
 /** @type {import('next').NextConfig} */
 const nextConfig = {
 const nextConfig = {
-  transpilePackages: ["@pipeline/shared", "@pipeline/core", "@pipeline/tts"],
+  transpilePackages: ["@pipeline/shared"],
+  serverExternalPackages: [
+    "@remotion/bundler",
+    "@remotion/renderer",
+    "@remotion/media-utils",
+    "esbuild",
+    "ali-oss",
+  ],
+  webpack: (config, { isServer }) => {
+    if (isServer) {
+      const prev = config.externals;
+      const prevArr = Array.isArray(prev) ? prev : prev ? [prev] : [];
+      config.externals = [
+        ...prevArr,
+        ({ request }, callback) => {
+          if (EXTERNALS.some((p) => request === p || request.startsWith(p + "/"))) {
+            return callback(null, `commonjs ${request}`);
+          }
+          callback();
+        },
+      ];
+    }
+    return config;
+  },
 };
 };
 
 
 export default nextConfig;
 export default nextConfig;

+ 1 - 0
apps/web/package.json

@@ -10,6 +10,7 @@
   "dependencies": {
   "dependencies": {
     "@pipeline/shared": "workspace:*",
     "@pipeline/shared": "workspace:*",
     "@pipeline/collect": "workspace:*",
     "@pipeline/collect": "workspace:*",
+    "@pipeline/core": "workspace:*",
     "next": "^15.3.0",
     "next": "^15.3.0",
     "node-cron": "^3.0.3",
     "node-cron": "^3.0.3",
     "react": "^19.0.0",
     "react": "^19.0.0",

+ 7 - 1
apps/web/src/app/api/collect/route.ts

@@ -1,7 +1,12 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
-import { getCollector, listCollectorNames } from "@pipeline/collect";
 import { loadConfig } from "@/lib/config";
 import { loadConfig } from "@/lib/config";
 
 
+// NOTE: @pipeline/collect is imported lazily inside the handler. It is a
+// server-only workspace package that reaches remotion/esbuild via the core
+// graph; importing it at module top level would make Next's build-time
+// page-data collection try to load it (which fails for the externalized ESM
+// package). Lazy-importing keeps it out of the build-time evaluation.
+
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
   const body = await request.json();
   const body = await request.json();
   const { source, args } = body as { source: string; args?: Record<string, string> };
   const { source, args } = body as { source: string; args?: Record<string, string> };
@@ -10,6 +15,7 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: "source is required" }, { status: 400 });
     return NextResponse.json({ error: "source is required" }, { status: 400 });
   }
   }
 
 
+  const { getCollector, listCollectorNames } = await import("@pipeline/collect");
   const available = listCollectorNames();
   const available = listCollectorNames();
   if (!available.includes(source)) {
   if (!available.includes(source)) {
     return NextResponse.json(
     return NextResponse.json(

+ 2 - 1
apps/web/src/app/api/collect/sources/route.ts

@@ -1,8 +1,9 @@
 import { NextResponse } from "next/server";
 import { NextResponse } from "next/server";
-import { listCollectorNames } from "@pipeline/collect";
 import { loadConfig } from "@/lib/config";
 import { loadConfig } from "@/lib/config";
 
 
+// @pipeline/collect imported lazily — see ../route.ts.
 export async function GET() {
 export async function GET() {
+  const { listCollectorNames } = await import("@pipeline/collect");
   const config = loadConfig();
   const config = loadConfig();
   const names = listCollectorNames();
   const names = listCollectorNames();
   const sources = names.map((name) => {
   const sources = names.map((name) => {

+ 80 - 135
apps/web/src/lib/run-render.ts

@@ -1,10 +1,8 @@
 import { createJob, updateJob } from "@/lib/job-store";
 import { createJob, updateJob } from "@/lib/job-store";
 import { loadConfig } from "@/lib/config";
 import { loadConfig } from "@/lib/config";
 import { resolveOutputDir } from "@pipeline/shared/node";
 import { resolveOutputDir } from "@pipeline/shared/node";
-import { spawn } from "node:child_process";
-import { writeFileSync, mkdirSync, statSync } from "node:fs";
-import { join, resolve } from "node:path";
-import { tmpdir } from "node:os";
+import type { DocumentRunConfig } from "@pipeline/core";
+import { resolve } from "node:path";
 
 
 export interface RenderJobParams {
 export interface RenderJobParams {
   template: string;
   template: string;
@@ -21,12 +19,18 @@ export interface RenderJobParams {
 }
 }
 
 
 /**
 /**
- * Spawn the compiled CLI to render a video, tracking it in the job store so it
- * shows up in the WebUI / jobs list exactly like a WebUI/HTTP-triggered render.
- * Shared by `POST /api/render` and the in-process scheduler.
+ * Run the core pipeline IN-PROCESS (no spawned CLI): the web service IS the
+ * core service host. Chains text → audio → renderer via runDocument, tracking
+ * the job in the store so it shows up in the WebUI / jobs list exactly like a
+ * scheduler-triggered render. Shared by `POST /api/render` and the scheduler.
  *
  *
- * Returns immediately with the `jobId`; `done` resolves when the child process
- * exits (success or failure) so callers that need ordering (e.g. the scheduler's
+ * `@pipeline/core` is imported LAZILY (dynamic import inside the run) so Next's
+ * build-time page-data collection never tries to evaluate the heavy
+ * core→renderer→remotion/esbuild chain (which is webpack-externalized). It only
+ * loads at actual runtime.
+ *
+ * Returns immediately with the `jobId`; `done` resolves when the pipeline
+ * finishes (success or failure) so callers that need ordering (the scheduler's
  * single-flight guard) can await it.
  * single-flight guard) can await it.
  */
  */
 export function startRenderJob(params: RenderJobParams): {
 export function startRenderJob(params: RenderJobParams): {
@@ -39,152 +43,93 @@ export function startRenderJob(params: RenderJobParams): {
   } = params;
   } = params;
 
 
   const job = createJob({ text: text ?? "", template, platforms, ttsProvider, voiceId });
   const job = createJob({ text: text ?? "", template, platforms, ttsProvider, voiceId });
+  updateJob(job.id, { status: "running" });
 
 
-  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
-  mkdirSync(jobDir, { recursive: true });
-
-  // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output).
   const config = loadConfig();
   const config = loadConfig();
   const outputDir = resolveOutputDir(config?.output?.dir);
   const outputDir = resolveOutputDir(config?.output?.dir);
-
-  const cliArgs = [
-    "render",
-    "-t", template,
-    "-p", platforms.join(","),
-    "--tts-provider", ttsProvider,
-    "--output", outputDir,
-  ];
-
-  if (source) {
-    cliArgs.push("--source", source);
-    if (sourceArgs?.owner) cliArgs.push("--source-owner", sourceArgs.owner);
-    if (sourceArgs?.repo) cliArgs.push("--source-repo", sourceArgs.repo);
-  } else {
-    const inputFile = join(jobDir, "input.json");
-    writeFileSync(inputFile, text!, "utf-8");
-    cliArgs.splice(1, 0, inputFile);
-  }
-  if (voiceId) cliArgs.push("--voice", voiceId);
-  if (noTts) cliArgs.push("--no-tts");
-  if (noPublish) cliArgs.push("--no-publish");
-
-  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
   const projectRoot = resolve(process.cwd(), "..");
   const projectRoot = resolve(process.cwd(), "..");
-
-  updateJob(job.id, { status: "running" });
-
-  const child = spawn("node", [cliPath, ...cliArgs], {
-    cwd: projectRoot,
-    stdio: ["ignore", "pipe", "pipe"],
-    env: { ...process.env },
-  });
+  const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
+  const assetsRoot = resolve(projectRoot, "assets");
+  const inputDir = resolve(process.cwd());
+  const providerConfig = config?.tts?.[ttsProvider];
 
 
   const jobIdShort = job.id.slice(0, 8);
   const jobIdShort = job.id.slice(0, 8);
-  const linePrefix = `[job ${jobIdShort}] `;
-  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
-  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
-
-  let stdout = "";
-  let stderr = "";
-  let logBuf = "";
-
-  const done = new Promise<void>((resolveDone) => {
-    // Capture for parsing/UI AND mirror to the container's stdout/stderr so
-    // `docker compose logs` shows every stage / TTS retry / publish event live.
-    child.stdout.on("data", (data: Buffer) => {
-      const s = data.toString();
-      stdout += s;
-      logBuf += s;
-      fwdOut.push(s);
-    });
-    child.stderr.on("data", (data: Buffer) => {
-      const s = data.toString();
-      stderr += s;
-      logBuf += s;
-      fwdErr.push(s);
-    });
-
-    child.on("close", (code: number) => {
-      fwdOut.flush();
-      fwdErr.flush();
-      const log = capLog(logBuf);
-      if (code === 0) {
-        const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
-        const filePaths = mp4Matches.map((m) => m[1].trim());
-
-        const outputFiles = filePaths.map((filePath) => {
-          let fileSizeBytes = 0;
-          try { fileSizeBytes = statSync(filePath).size; } catch {}
-          const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
-          return {
-            filePath,
-            fileSizeBytes,
-            width: isPortrait ? 1080 : 1920,
-            height: isPortrait ? 1920 : 1080,
-            durationSeconds: 0,
-          };
-        });
-
-        const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map((m) => m[1].trim());
-        const publishWarn = stdout.match(/^Publish warning: (.+)$/m);
-        const publishError = publishWarn ? publishWarn[1].trim() : undefined;
 
 
+  const done = (async () => {
+    // Lazy-load core (heavy: pulls remotion/esbuild, webpack-externalized).
+    const { runDocument, resolvePublishConfig } = await import("@pipeline/core");
+    const publish = noPublish ? undefined : resolvePublishConfig(config);
+
+    const runConfig: DocumentRunConfig = {
+      branding: { channelName: config?.branding?.channelName ?? "Pipeline" },
+      llm: { model: config?.llm?.model ?? "glm-5.1" },
+      tts: {
+        provider: ttsProvider,
+        voiceId: voiceId || providerConfig?.defaultVoice,
+        model: providerConfig?.model,
+        format: config?.tts?.format,
+        speed: config?.tts?.speed,
+        alignment: config?.tts?.alignment,
+      },
+      output: {
+        dir: outputDir,
+        retentionDays: Number(process.env.OUTPUT_RETENTION_DAYS ?? config?.output?.retentionDays ?? 30),
+      },
+      publish,
+      publishMeta: config?.publishMeta,
+      collect: config?.collect,
+      assets: { root: assetsRoot, inputDir },
+      templates: { entryPoint: templatesEntry },
+    };
+
+    try {
+      const result = await runDocument(
+        {
+          text,
+          source,
+          sourceArgs,
+          template: template as any,
+          platforms: platforms as any,
+          ttsProvider,
+          voiceId,
+          skipTts: noTts,
+          skipPublish: noPublish,
+        },
+        runConfig
+      );
+      if (result.status === "completed") {
+        const outputFiles = result.files.map((f) => ({
+          filePath: f.filePath,
+          fileSizeBytes: f.fileSizeBytes,
+          width: f.width,
+          height: f.height,
+          durationSeconds: f.durationSeconds,
+        }));
+        const ossUrls = result.files
+          .map((f) => f.ossUrl)
+          .filter((u): u is string => !!u);
         updateJob(job.id, {
         updateJob(job.id, {
           status: "completed",
           status: "completed",
           outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
           outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
           ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
           ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
-          publishError,
-          log,
+          publishError: result.publishError,
+          log: `job ${jobIdShort} completed (${result.files.length} file(s))`,
         });
         });
       } else {
       } else {
         updateJob(job.id, {
         updateJob(job.id, {
           status: "failed",
           status: "failed",
-          error: stderr.trim() || `Process exited with code ${code}`,
-          log,
+          error: result.error ?? "unknown error",
+          log: `job ${jobIdShort} failed: ${result.error ?? ""}`,
         });
         });
       }
       }
-      resolveDone();
-    });
-
-    child.on("error", (err: Error) => {
-      fwdOut.flush();
-      fwdErr.flush();
+    } catch (err) {
       updateJob(job.id, {
       updateJob(job.id, {
         status: "failed",
         status: "failed",
-        error: err.message,
-        log: capLog(logBuf),
+        error: err instanceof Error ? err.message : String(err),
+        log: `job ${jobIdShort} error: ${err instanceof Error ? err.message : String(err)}`,
       });
       });
-      resolveDone();
-    });
-  });
+    }
+  })();
 
 
   return { jobId: job.id, done };
   return { jobId: job.id, done };
 }
 }
-
-// Keep the per-job log bounded so jobs.json doesn't grow without limit.
-const LOG_CAP = 200_000;
-function capLog(buf: string): string {
-  if (buf.length <= LOG_CAP) return buf;
-  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
-}
-
-// Line-buffered forwarder: writes each complete line (prefixed with the job id)
-// to the container's stdout/stderr, holding back a trailing partial line until
-// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
-function makePrefixer(prefix: string, write: (s: string) => void) {
-  let pending = "";
-  return {
-    push(chunk: string) {
-      pending += chunk;
-      const lines = pending.split("\n");
-      pending = lines.pop() ?? "";
-      for (const line of lines) write(prefix + line + "\n");
-    },
-    flush() {
-      if (pending.length > 0) {
-        write(prefix + pending + "\n");
-        pending = "";
-      }
-    },
-  };
-}

+ 3 - 0
config/default.yaml

@@ -108,6 +108,9 @@ templates:
 collect:
 collect:
   github-trending:
   github-trending:
     url: "https://github.crawler.corp.shuidi.tech/api/trending"
     url: "https://github.crawler.corp.shuidi.tech/api/trending"
+    # 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"
   github-repo:
   github-repo:
     url: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
     url: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
   github-daily:
   github-daily:

+ 64 - 0
docs/项目解耦

@@ -0,0 +1,64 @@
+现在项目要进行结构上的解耦,将整体流程拆分为3个板块:
+第一个是AI文字部分,主要负责协调数据源和生成基础JSON,比如对于github-trending这个模板来说,它要:
+1. 读取各个仓库的README及其他基本数据,生成单个仓库的描述
+2. 根据上一步的结果总结内容,获取首屏或者一些全局描述相关的内容
+3. 根据步骤1、步骤2生成章节台词内容
+4. 视频发布所需的配置信息应该在本阶段生成(标题、描述、标签、板块等)
+
+另外,数据源与AI文字逻辑在模块内需要拆分开,为以后可能的混合数据源做准备,之后可能会有混合多个数据源去生成视频的需求
+
+然后是AI音频部分,需要读取AI文字部分中生成的JSON中的caption_origin来生成音频,然后根据生成的音频拆分为字幕数组(文字、所需时长)。整个AI音频部分就是通过遍历AI文字部分中生成的JSON来执行上述步骤,并将结果合并到JSON
+
+最后是remotion调度器,remotion调度器也是接收固定的JSON类型(其实每个步骤都是,不满足JSON类型要报错),然后遍历JSON下载所有音频(文件命名要根据JSON字段path来命名(共用工具函数)),下载完毕后,合并到JSON
+
+然后调用指定的remotion模板,消费JSON,根据模板生成视频。最终将生成好的视频上传到OSS。调用模板生成视频这里需要有方便的方法进行调试(remotion应该提供直接预览模板的方法),这样开发新模板便不再需要跑完整流程。
+
+以下是JSON模板的参考示例,可根据实际情况进行调整:
+interface Input {
+  // 数据结构版本
+  version: "2.0";
+  // 数据类型(后面可能非ppt形式表达)
+  type: "ppt";
+  // 全局配置
+  config?: {
+    // 主题色
+    theme_color?: string;
+    // 背景音乐
+    bgm_file_url?: string;
+    // ...
+  };
+  data: Array<{
+    title?: string;
+    desc?: string;
+    // 本段落完整字幕
+    caption_origin?: "你好,我叫张三";
+    // 拆分后字幕
+    caption?: {
+      text: string;
+      // 需要占用的时长
+      duration: number;
+    }[];
+    // 音频文件url
+    caption_audio_file_url?: "";
+    card_list?: {
+      title: "";
+      desc: "";
+      // ...
+    }[];
+    //
+    menu?: {
+      icon?: "";
+      title: "";
+      desc: "";
+      // ...
+    }[];
+  }>;
+}
+
+这些只是项目流程上的变更,我不希望上面的变更会影响视频生成结果。所有的功能,以github-trending这个模板为准,目的是为项目提供更多扩展上的可能。
+
+基于以上流程,我们或许需要一个新的程序运行方式,该运行方式主要是以后端服务的方式运行,而不是CLI或webui。更适配容器化的运行方式,原生支持定时执行。该程序运行方式将是pipeline的主要运行方式,暂时不考虑CLI或webui的兼容性和可用性。另外或许我们之后还要考虑并发问题,这个可以不做,在架构上保留这种能力
+
+这次改动非常大,应该基本相当于重构了,CLAUDE.md也需要大范围改动甚至重写
+
+检查当下的项目结构与新结构是否有逻辑上的矛盾冲突,如果有,及时告知我;如果没有,请规划后开始进行调整

+ 26 - 0
packages/audio/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@pipeline/audio",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@pipeline/shared": "workspace:*",
+    "@pipeline/tts": "workspace:*",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

+ 61 - 0
packages/audio/src/index.ts

@@ -0,0 +1,61 @@
+import { mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import type { VideoDocument } from "@pipeline/shared";
+import { wordsToCaptions } from "./segment.js";
+import {
+  synthesizeSegment,
+  silentSegmentAudio,
+  applyModelEnv,
+  type SynthesizeConfig,
+} from "./synthesize.js";
+import type { AudioModuleOptions } from "./types.js";
+
+export type { AudioModuleOptions } from "./types.js";
+export { wordsToCaptions } from "./segment.js";
+
+/**
+ * AI 音频模块(Module 2)入口。
+ *
+ * 遍历 VideoDocument.data:读取每段的 captionOrigin 合成音频(@pipeline/tts),
+ * 再用 wordsToCaptions(移植自 SubtitleBar.groupIntoSegments)把对齐后的词时间
+ * 一次性拆成 caption[{ text, duration }],连同 caption_audio_file_url 与视觉时长
+ * 回填进 VideoDocument。无 captionOrigin 的段(如静默封面)保持原样。
+ *
+ * 纯函数式:返回一份新的 VideoDocument,不改入参(并发友好)。
+ */
+export async function generateAudio(
+  doc: VideoDocument,
+  opts: AudioModuleOptions
+): Promise<VideoDocument> {
+  const ttsDir = join(opts.workDir, "tts");
+  await mkdir(ttsDir, { recursive: true });
+  if (!opts.skip) applyModelEnv(opts.provider, opts.model);
+
+  const synthCfg: SynthesizeConfig = {
+    provider: opts.provider,
+    voiceId: opts.voiceId,
+    model: opts.model,
+    format: opts.format,
+    speed: opts.speed,
+    alignment: opts.alignment,
+  };
+
+  const data = doc.data.map((seg) => ({ ...seg }));
+
+  for (const seg of data) {
+    if (!(seg.captionOrigin ?? "").trim()) {
+      // No narration → no audio. (Visual duration is the template's concern.)
+      seg.caption = undefined;
+      seg.captionAudioFileUrl = undefined;
+      seg.duration = 0;
+      continue;
+    }
+
+    const res = opts.skip ? silentSegmentAudio(seg) : await synthesizeSegment(seg, ttsDir, synthCfg);
+    seg.caption = wordsToCaptions(res.wordTimestamps);
+    seg.captionAudioFileUrl = res.audioFilePath || undefined;
+    seg.duration = res.audioDuration; // audio length only
+  }
+
+  return { ...doc, data };
+}

+ 49 - 0
packages/audio/src/segment.ts

@@ -0,0 +1,49 @@
+import type { WordTimestamp, Caption } from "@pipeline/shared";
+
+const SENTENCE_END = /[。!?;]/;
+const CLAUSE_BREAK = /[,、,;]/;
+
+/**
+ * Pure port of the legacy `SubtitleBar.groupIntoSegments`
+ * (packages/templates/src/base/components/subtitle-bar.tsx), but emits the
+ * canonical `Caption[]` (text + duration) up front instead of grouping at render
+ * time. Same boundaries, so on-screen subtitle timing is identical:
+ *  - a sentence-ending char (。!?;) always ends a caption;
+ *  - a comma/semicolon (,、,;) ends a caption once ≥8 words have accumulated
+ *    since the last break;
+ *  - the final word always ends a caption.
+ *
+ * The displayed text strips display punctuation/whitespace, matching the legacy
+ * render filter (`/[。,!?\s]/`).
+ */
+export function wordsToCaptions(wordTimestamps: WordTimestamp[]): Caption[] {
+  if (wordTimestamps.length === 0) return [];
+
+  const ends = new Set<number>();
+  let lastEnd = -1;
+  for (let i = 0; i < wordTimestamps.length; i++) {
+    const w = wordTimestamps[i].word;
+    if (SENTENCE_END.test(w)) {
+      ends.add(i);
+      lastEnd = i;
+    } else if (CLAUSE_BREAK.test(w) && i - lastEnd >= 8) {
+      ends.add(i);
+      lastEnd = i;
+    }
+  }
+  ends.add(wordTimestamps.length - 1);
+
+  const captions: Caption[] = [];
+  let segStart = 0;
+  for (const end of [...ends].sort((a, b) => a - b)) {
+    const words = wordTimestamps.slice(segStart, end + 1);
+    const text = words
+      .map((w) => w.word)
+      .join("")
+      .replace(/[。,!?\s]/g, "");
+    const duration = Math.max(0, words[words.length - 1].endSeconds - words[0].startSeconds);
+    captions.push({ text, duration });
+    segStart = end + 1;
+  }
+  return captions;
+}

+ 100 - 0
packages/audio/src/synthesize.ts

@@ -0,0 +1,100 @@
+import { getProvider, alignWithWhisper } from "@pipeline/tts";
+import type { WordTimestamp, VideoSegment } from "@pipeline/shared";
+
+export interface SegmentAudioResult {
+  audioFilePath: string;
+  /** AUDIO duration in seconds — the synthesized audio length. NOT the visual
+   *  duration (the template computes that, may add silent padding). */
+  audioDuration: number;
+  wordTimestamps: WordTimestamp[];
+}
+
+export interface SynthesizeConfig {
+  provider: string;
+  voiceId?: string;
+  model?: string;
+  format?: "mp3" | "wav" | "pcm";
+  speed?: number;
+  alignment?: {
+    provider: "whisper" | "native";
+    whisperModel?: string;
+    language?: string;
+  };
+}
+
+/** Inject the configured model name as the provider-specific env var (mirrors
+ *  the legacy tts stage) so providers pick it up alongside their env config.
+ *  NOTE: mutates process.env — a shared-process concern (acceptable while the
+ *  service is single-process; flagged for the future multi-replica path). */
+export function applyModelEnv(provider: string, model?: string): void {
+  if (!model) return;
+  const envMap: Record<string, string> = {
+    "openai-tts": "OPENAI_TTS_MODEL",
+    "fish-audio": "FISH_AUDIO_MODEL",
+    "minimax": "MINIMAX_MODEL",
+    "elevenlabs": "ELEVENLABS_MODEL",
+  };
+  const envKey = envMap[provider];
+  if (envKey && !process.env[envKey]) process.env[envKey] = model;
+}
+
+/** Synthesize one segment's captionOrigin to audio + word timestamps. Ports the
+ *  per-scene loop of the legacy tts stage. The provider writes
+ *  `<ttsDir>/<id>.<format>` (= audioFilenameFor(id, format)), returned as
+ *  audioFilePath. */
+export async function synthesizeSegment(
+  seg: VideoSegment,
+  ttsDir: string,
+  cfg: SynthesizeConfig
+): Promise<SegmentAudioResult> {
+  const provider = getProvider(cfg.provider);
+  const globalSpeed = cfg.speed ?? 1.0;
+  const useWhisper = cfg.alignment?.provider === "whisper";
+
+  const sceneSpeed = seg.speed ?? globalSpeed;
+  const result = await provider.synthesize({
+    text: seg.captionOrigin!,
+    voiceId: cfg.voiceId || "",
+    format: cfg.format || "mp3",
+    speed: sceneSpeed,
+    outputDir: ttsDir,
+    filename: seg.id,
+  });
+
+  const audioDuration = result.durationSeconds;
+
+  let wordTimestamps = result.wordTimestamps;
+  if (useWhisper && result.audioFilePath) {
+    const aligned = await alignWithWhisper({
+      audioFilePath: result.audioFilePath,
+      text: seg.captionOrigin!,
+      model: cfg.alignment?.whisperModel,
+      language: cfg.alignment?.language,
+    });
+    if (aligned) wordTimestamps = aligned;
+  }
+
+  // duration is the AUDIO length only — the visual/scene duration is the
+  // template's concern (it may extend beyond audio for holds/silent padding).
+  return { audioFilePath: result.audioFilePath, audioDuration, wordTimestamps };
+}
+
+/** --no-tts debug path: no real audio, but synthetic evenly-distributed word
+ *  timestamps so subtitles still render. Returns a rough spoken-duration
+ *  estimate (no real audio to measure). */
+export function silentSegmentAudio(seg: VideoSegment): SegmentAudioResult {
+  const text = seg.captionOrigin ?? "";
+  const words = text.split(/\s+/).filter(Boolean);
+  // Rough spoken estimate (~4 chars/sec for Chinese). --no-tts debug only.
+  const audioDuration = Math.max(3, Math.ceil(text.length / 4));
+  const per = audioDuration / Math.max(words.length, 1);
+  return {
+    audioFilePath: "",
+    audioDuration,
+    wordTimestamps: words.map((word, i) => ({
+      word,
+      startSeconds: i * per,
+      endSeconds: (i + 1) * per,
+    })),
+  };
+}

+ 16 - 0
packages/audio/src/types.ts

@@ -0,0 +1,16 @@
+export interface AudioModuleOptions {
+  /** Per-job working directory. Audio is written to `<workDir>/tts/<id>.<format>`. */
+  workDir: string;
+  provider: string;
+  voiceId?: string;
+  model?: string;
+  format?: "mp3" | "wav" | "pcm";
+  speed?: number;
+  /** --no-tts debug path: synthesize no real audio (silent), still emit captions. */
+  skip?: boolean;
+  alignment?: {
+    provider: "whisper" | "native";
+    whisperModel?: string;
+    language?: string;
+  };
+}

+ 13 - 0
packages/audio/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "types": ["node"]
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../shared" },
+    { "path": "../tts" }
+  ]
+}

+ 16 - 21
packages/collect/src/collectors/github-daily.ts

@@ -1,7 +1,7 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { extractItems } from "../types.js";
 import { extractItems } from "../types.js";
 import { registerCollector } from "../registry.js";
 import { registerCollector } from "../registry.js";
-import { formatCount } from "@pipeline/shared";
+import { formatCount, type RepoMeta } from "@pipeline/shared";
 
 
 class GitHubDailyCollector implements DataSource {
 class GitHubDailyCollector implements DataSource {
   readonly name = "github-daily";
   readonly name = "github-daily";
@@ -33,6 +33,7 @@ class GitHubDailyCollector implements DataSource {
 
 
     // Step 2: fetch details for each repo
     // Step 2: fetch details for each repo
     const details: string[] = [];
     const details: string[] = [];
+    const collectedRepos: RepoMeta[] = [];
     for (const repo of repos) {
     for (const repo of repos) {
       const owner = repo.author ?? repo.owner?.login ?? "";
       const owner = repo.author ?? repo.owner?.login ?? "";
       const name = repo.name ?? "";
       const name = repo.name ?? "";
@@ -47,7 +48,9 @@ class GitHubDailyCollector implements DataSource {
         if (!res.ok) continue;
         if (!res.ok) continue;
         const raw = await res.json() as any;
         const raw = await res.json() as any;
         const data = raw.data ?? raw;
         const data = raw.data ?? raw;
-        details.push(formatRepo(owner, name, data));
+        const { text, meta } = formatRepo(owner, name, data);
+        details.push(text);
+        if (meta) collectedRepos.push(meta);
       } catch {
       } catch {
         // Skip failed repo lookups
         // Skip failed repo lookups
       }
       }
@@ -59,7 +62,7 @@ class GitHubDailyCollector implements DataSource {
       ? header + "\n\n---\n\n" + details.join("\n\n---\n\n")
       ? header + "\n\n---\n\n" + details.join("\n\n---\n\n")
       : header;
       : header;
 
 
-    return { type: "text", content };
+    return { type: "text", content, repos: collectedRepos };
   }
   }
 }
 }
 
 
@@ -72,7 +75,7 @@ function formatTrendingSummary(repos: any[]): string {
   return lines.join("\n");
   return lines.join("\n");
 }
 }
 
 
-function formatRepo(trendingOwner: string, trendingName: string, data: any): string {
+function formatRepo(trendingOwner: string, trendingName: string, data: any): { text: string; meta: RepoMeta | null } {
   // The repo detail API returns the REAL owner (e.g. facebook/react was
   // The repo detail API returns the REAL owner (e.g. facebook/react was
   // transferred to the react org and now reports fullName "react/react").
   // transferred to the react org and now reports fullName "react/react").
   // Prefer the detail's fullName when available, otherwise fall back to the
   // Prefer the detail's fullName when available, otherwise fall back to the
@@ -80,13 +83,7 @@ function formatRepo(trendingOwner: string, trendingName: string, data: any): str
   const fullName = data.fullName || `${trendingOwner}/${trendingName}`;
   const fullName = data.fullName || `${trendingOwner}/${trendingName}`;
   const [detailOwner, detailName] = fullName.split("/");
   const [detailOwner, detailName] = fullName.split("/");
 
 
-  const lines: string[] = [`## ${fullName}`];
-
-  if (data.description) lines.push(`\n${data.description}`);
-
-  // Structured metadata block — the LLM is instructed to copy this verbatim
-  // into scene.github.repo. Do NOT edit field names or values here.
-  const meta = {
+  const meta: RepoMeta = {
     owner: detailOwner || trendingOwner,
     owner: detailOwner || trendingOwner,
     name: detailName || trendingName,
     name: detailName || trendingName,
     fullName,
     fullName,
@@ -96,23 +93,21 @@ function formatRepo(trendingOwner: string, trendingName: string, data: any): str
     forks: data.forks,
     forks: data.forks,
     license: data.license ?? "",
     license: data.license ?? "",
   };
   };
-  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 
 
-  // Image refs — the LLM copies these into scene.images[] verbatim.
-  // socialPreview is resolved by the assets stage (calls crawler, decodes
-  // base64 PNG). starHistory is fetched as a normal URL image.
-  const imageRefs = {
-    socialPreview: `${detailOwner || trendingOwner}/${detailName || trendingName}`,
-    starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
-  };
-  lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
+  const lines: string[] = [`## ${fullName}`];
+
+  if (data.description) lines.push(`\n${data.description}`);
+
+  // Structured metadata block — the LLM copies this verbatim into scene.github.repo.
+  // Do NOT edit field names or values here.
+  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 
 
   if (data.readme) {
   if (data.readme) {
     const readme = data.readme.length > 2000 ? data.readme.slice(0, 2000) + "\n..." : data.readme;
     const readme = data.readme.length > 2000 ? data.readme.slice(0, 2000) + "\n..." : data.readme;
     lines.push(`\n### README\n${readme}`);
     lines.push(`\n### README\n${readme}`);
   }
   }
 
 
-  return lines.join("\n");
+  return { text: lines.join("\n"), meta };
 }
 }
 
 
 registerCollector("github-daily", (config) => new GitHubDailyCollector(config));
 registerCollector("github-daily", (config) => new GitHubDailyCollector(config));

+ 11 - 16
packages/collect/src/collectors/github-repo.ts

@@ -1,6 +1,6 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { registerCollector } from "../registry.js";
 import { registerCollector } from "../registry.js";
-import { formatCount } from "@pipeline/shared";
+import { formatCount, type RepoMeta } from "@pipeline/shared";
 
 
 class GitHubRepoCollector implements DataSource {
 class GitHubRepoCollector implements DataSource {
   readonly name = "github-repo";
   readonly name = "github-repo";
@@ -33,21 +33,18 @@ class GitHubRepoCollector implements DataSource {
     const raw = (await response.json()) as Record<string, any>;
     const raw = (await response.json()) as Record<string, any>;
     const data = (raw.data ?? raw) as Record<string, any>;
     const data = (raw.data ?? raw) as Record<string, any>;
 
 
-    return { type: "text", content: formatRepoDetail(owner, repo, data) };
+    const { text, meta } = formatRepoDetail(owner, repo, data);
+    return { type: "text", content: text, repos: meta ? [meta] : [] };
   }
   }
 }
 }
 
 
-function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): string {
+function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): { text: string; meta: RepoMeta | null } {
   // The API returns the REAL owner (transferred repos report their current
   // The API returns the REAL owner (transferred repos report their current
   // owner). Prefer data.fullName; fall back to the query params.
   // owner). Prefer data.fullName; fall back to the query params.
   const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
   const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
   const [detailOwner, detailName] = fullName.split("/");
   const [detailOwner, detailName] = fullName.split("/");
 
 
-  const lines: string[] = [`# ${fullName}\n`];
-  if (data.description) lines.push(`${data.description}\n`);
-
-  // Structured metadata block — LLM copies verbatim into scene.github.repo.
-  const meta = {
+  const meta: RepoMeta = {
     owner: detailOwner || queryOwner,
     owner: detailOwner || queryOwner,
     name: detailName || queryRepo,
     name: detailName || queryRepo,
     fullName,
     fullName,
@@ -57,14 +54,12 @@ function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): str
     forks: data.forks,
     forks: data.forks,
     license: data.license ?? "",
     license: data.license ?? "",
   };
   };
-  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 
 
-  // Image refs — LLM copies verbatim into scene.images[].
-  const imageRefs = {
-    socialPreview: `${detailOwner || queryOwner}/${detailName || queryRepo}`,
-    starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
-  };
-  lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
+  const lines: string[] = [`# ${fullName}\n`];
+  if (data.description) lines.push(`${data.description}\n`);
+
+  // Structured metadata block — LLM copies verbatim into scene.github.repo.
+  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 
 
   const meta2: string[] = [];
   const meta2: string[] = [];
   if (data.language) meta2.push(`Language: ${data.language}`);
   if (data.language) meta2.push(`Language: ${data.language}`);
@@ -84,7 +79,7 @@ function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): str
     lines.push("\n## README\n", readme);
     lines.push("\n## README\n", readme);
   }
   }
 
 
-  return lines.join("\n");
+  return { text: lines.join("\n"), meta };
 }
 }
 
 
 registerCollector("github-repo", (config) => new GitHubRepoCollector(config));
 registerCollector("github-repo", (config) => new GitHubRepoCollector(config));

+ 62 - 20
packages/collect/src/collectors/github-trending.ts

@@ -1,14 +1,23 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { extractItems } from "../types.js";
 import { extractItems } from "../types.js";
 import { registerCollector } from "../registry.js";
 import { registerCollector } from "../registry.js";
-import { formatCount } from "@pipeline/shared";
+import { formatCount, type RepoMeta } from "@pipeline/shared";
+
+/** How many of the top-by-todayStars repos to actually describe. Fetching
+ *  READMEs for the whole trending list would blow the LLM context, so we rank
+ *  by today's star gain and keep only the slice the video will cover (the text
+ *  module later takes the top 6). */
+const README_FETCH_LIMIT = 8;
+const README_TRUNCATE = 2000;
 
 
 class GitHubTrendingCollector implements DataSource {
 class GitHubTrendingCollector implements DataSource {
   readonly name = "github-trending";
   readonly name = "github-trending";
   private url: string;
   private url: string;
+  private repoUrlTemplate: string;
 
 
   constructor(config?: Record<string, any>) {
   constructor(config?: Record<string, any>) {
     this.url = config?.url ?? "";
     this.url = config?.url ?? "";
+    this.repoUrlTemplate = config?.repoUrl ?? "";
   }
   }
 
 
   async collect(_params?: CollectParams): Promise<CollectResult> {
   async collect(_params?: CollectParams): Promise<CollectResult> {
@@ -24,20 +33,28 @@ class GitHubTrendingCollector implements DataSource {
     const raw = await response.json();
     const raw = await response.json();
     const items = extractItems(raw);
     const items = extractItems(raw);
 
 
+    // Rank by today's star gain (desc) and keep the top slice we will describe.
+    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 Trending\n"];
     const lines: string[] = ["# GitHub Trending\n"];
-    for (const repo of items) {
+    const repos: RepoMeta[] = [];
+
+    for (const { repo, readme } of enriched) {
       const owner = repo.author ?? "";
       const owner = repo.author ?? "";
       const name = repo.name ?? "";
       const name = repo.name ?? "";
       const fullName = repo.fullName || `${owner}/${name}`;
       const fullName = repo.fullName || `${owner}/${name}`;
       if (!owner || !name) continue;
       if (!owner || !name) continue;
 
 
-      lines.push(`## ${fullName}`);
-      if (repo.description) lines.push(`${repo.description}`);
-
-      // Structured metadata block — LLM copies verbatim into scene.github.repo.
-      // The trending endpoint does not expose license; downstream template
-      // tolerates an empty license (no license tag rendered).
-      const meta = {
+      // Typed metadata — the authoritative channel the text module merges onto
+      // each scene (data source ↔ AI text separation).
+      const meta: RepoMeta = {
         owner,
         owner,
         name,
         name,
         fullName,
         fullName,
@@ -46,19 +63,18 @@ class GitHubTrendingCollector implements DataSource {
         stars: repo.stars,
         stars: repo.stars,
         forks: repo.forks,
         forks: repo.forks,
         license: "",
         license: "",
-        // Today's star gain, carried as structured data so the cover scene can
-        // render "+N" per repo. (Also emitted as a `Today: +N` text line below
-        // as a human-readable fallback the LLM can read if this is absent.)
         todayStars: repo.currentPeriodStars ?? undefined,
         todayStars: repo.currentPeriodStars ?? undefined,
       };
       };
-      lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+      repos.push(meta);
 
 
-      // Image refs — LLM copies verbatim into scene.images[].
-      const imageRefs = {
-        socialPreview: `${owner}/${name}`,
-        starHistory: `https://api.star-history.com/svg?repos=${fullName}&type=Date`,
-      };
-      lines.push(`<!-- repo-images: ${JSON.stringify(imageRefs)} -->`);
+      lines.push(`## ${fullName}`);
+      if (repo.description) lines.push(`${repo.description}`);
+
+      // Legacy structured metadata comment — the LLM copies this verbatim into
+      // scene.github.repo (used for scene identity + top6 sort; the text module
+      // then overrides it with the typed `repos` value). Retained; the legacy
+      // repo-images comment was removed (images are now template-declared).
+      lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 
 
       const meta2: string[] = [];
       const meta2: string[] = [];
       if (repo.language) meta2.push(`Language: ${repo.language}`);
       if (repo.language) meta2.push(`Language: ${repo.language}`);
@@ -68,10 +84,36 @@ class GitHubTrendingCollector implements DataSource {
       if (meta2.length) {
       if (meta2.length) {
         lines.push(meta2.map((m) => `- ${m}`).join("\n"));
         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("");
       lines.push("");
     }
     }
 
 
-    return { type: "text", content: lines.join("\n") };
+    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;
+    }
   }
   }
 }
 }
 
 

+ 2 - 2
packages/collect/src/types.ts

@@ -1,7 +1,7 @@
-import type { VideoInput } from "@pipeline/shared";
+import type { VideoInput, RepoMeta } from "@pipeline/shared";
 
 
 export type CollectResult =
 export type CollectResult =
-  | { type: "text"; content: string }
+  | { type: "text"; content: string; repos?: RepoMeta[] }
   | { type: "json"; content: VideoInput };
   | { type: "json"; content: VideoInput };
 
 
 export interface CollectParams {
 export interface CollectParams {

+ 3 - 8
packages/core/package.json

@@ -17,14 +17,9 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@pipeline/shared": "workspace:*",
     "@pipeline/shared": "workspace:*",
-    "@pipeline/tts": "workspace:*",
-    "@pipeline/templates": "workspace:*",
-    "@remotion/renderer": "^4.0.0",
-    "@remotion/bundler": "^4.0.0",
-    "ali-oss": "^6.21.0",
-    "zod": "^3.24.0",
-    "tmp-promise": "^3.0.3",
-    "yaml": "^2.7.0"
+    "@pipeline/text": "workspace:*",
+    "@pipeline/audio": "workspace:*",
+    "@pipeline/renderer": "workspace:*"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/node": "^22.0.0"
     "@types/node": "^22.0.0"

+ 184 - 0
packages/core/src/document.ts

@@ -0,0 +1,184 @@
+import { randomUUID } from "node:crypto";
+import { mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import type { VideoDocument, ExportFile, PublishMeta, TemplateType, PlatformPreset } from "@pipeline/shared";
+import { createLogger } from "@pipeline/shared/node";
+import { generateDocument } from "@pipeline/text";
+import { generateAudio } from "@pipeline/audio";
+import { renderDocument } from "@pipeline/renderer";
+import type { PublishTargetConfig } from "@pipeline/renderer";
+import { cleanupExpiredOutput } from "./cleanup.js";
+import {
+  notifyFeishuSuccess,
+  notifyGenerationFailure,
+  type PublishConfig,
+} from "./publish/index.js";
+
+const log = createLogger("pipeline");
+
+export interface DocumentRunInput {
+  template: TemplateType;
+  /** Raw text (JSON / markdown / plain). Required when `source` is absent. */
+  text?: string;
+  source?: string;
+  sourceArgs?: Record<string, string>;
+  platforms: PlatformPreset[];
+  ttsProvider: string;
+  voiceId?: string;
+  skipTts?: boolean;
+  skipLlm?: boolean;
+  /** Skip OSS upload + Feishu notification. */
+  skipPublish?: boolean;
+}
+
+export interface DocumentRunConfig {
+  llm: { baseURL?: string; apiKey?: string; model: string };
+  tts: {
+    provider: string;
+    voiceId?: string;
+    model?: string;
+    format?: "mp3" | "wav" | "pcm";
+    speed?: number;
+    alignment?: { provider: "whisper" | "native"; whisperModel?: string; language?: string };
+  };
+  output: { dir: string; retentionDays?: number };
+  assets: { root: string; inputDir: string };
+  templates: { entryPoint: string };
+  branding: { channelName: string };
+  /** config.collect — passed to the collector as config.collect[source]. */
+  collect?: Record<string, any>;
+  /** OSS (renderer uploads) + Feishu (orchestrator notifies). */
+  publish?: PublishConfig;
+  /** publishMeta[platform][template] — platform-specific sidecar keys. */
+  publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
+}
+
+export interface DocumentRunCallbacks {
+  onStage?: (stage: string) => void;
+}
+
+export interface DocumentRunResult {
+  jobId: string;
+  status: "completed" | "failed";
+  doc?: VideoDocument;
+  files: ExportFile[];
+  error?: string;
+  publishError?: string;
+}
+
+/** Feishu title suggestion: video title joined with the trend one-liner. */
+function buildTitleSuggestion(doc?: VideoDocument): string | undefined {
+  const t = doc?.meta?.title;
+  if (!t) return undefined;
+  const trend = doc?.meta?.trendSummary;
+  return trend ? `${t}|${trend}` : t;
+}
+
+/**
+ * Core orchestrator — the "service" logic. Chains the three decoupled modules
+ * (text → audio → renderer) in-process, then notifies Feishu (OSS upload happens
+ * inside the renderer, which fills ossUrl on each file). Runs entirely in the
+ * caller's process — the web service and the CLI both call this directly.
+ *
+ * Statelesss given (input, config); the workDir is per-job. Concurrency-ready
+ * (no module-level mutable state).
+ */
+export async function runDocument(
+  input: DocumentRunInput,
+  config: DocumentRunConfig,
+  callbacks?: DocumentRunCallbacks
+): Promise<DocumentRunResult> {
+  const jobId = randomUUID();
+  const t0 = Date.now();
+  const workDir = join(config.output.dir, "tmp", jobId);
+  await mkdir(workDir, { recursive: true });
+
+  // Opportunistic cache sweep (best-effort, never throws).
+  if (config.output.retentionDays && config.output.retentionDays > 0) {
+    try {
+      const cleaned = await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
+      log.debug(`cleanup scanned=${cleaned.scanned} removed=${cleaned.removed} freed=${Math.round(cleaned.bytesFreed / 1024 / 1024)}MB`);
+    } catch {}
+  }
+
+  const publishConfig = !input.skipPublish ? config.publish : undefined;
+  let publishMeta: PublishMeta | undefined;
+  const result: DocumentRunResult = { jobId, status: "completed", files: [] };
+  let doc: VideoDocument | undefined;
+
+  log.info(
+    `job ${jobId} start template=${input.template} platforms=${input.platforms.join(",")} ` +
+      `source=${input.source ?? "(text)"} skipLlm=${!!input.skipLlm} skipTts=${!!input.skipTts}`
+  );
+
+  const publishCtx = () => ({
+    jobId,
+    template: input.template,
+    platforms: input.platforms as string[],
+    titleSuggestion: buildTitleSuggestion(doc),
+  });
+
+  try {
+    callbacks?.onStage?.("text");
+    // generateDocument returns the pure VideoDocument + separate posting metadata.
+    const textOut = await generateDocument({
+      template: input.template,
+      text: input.text,
+      source: input.source,
+      sourceArgs: input.sourceArgs,
+      collectorConfig: input.source ? config.collect?.[input.source] : undefined,
+      llm: config.llm,
+      skipLlm: input.skipLlm,
+    });
+    doc = textOut.doc;
+    publishMeta = textOut.publish;
+
+    callbacks?.onStage?.("audio");
+    doc = await generateAudio(doc, {
+      workDir,
+      provider: input.ttsProvider,
+      voiceId: input.voiceId || config.tts.voiceId,
+      model: config.tts.model,
+      format: config.tts.format,
+      speed: config.tts.speed,
+      skip: input.skipTts,
+      alignment: config.tts.alignment,
+    });
+
+    callbacks?.onStage?.("render");
+    const files = await renderDocument(doc, {
+      platforms: input.platforms,
+      workDir,
+      jobId,
+      outputDir: config.output.dir,
+      assetsRoot: config.assets.root,
+      inputDir: config.assets.inputDir,
+      templatesEntry: config.templates.entryPoint,
+      channelName: config.branding.channelName,
+      publish: publishMeta,
+      publishMeta: config.publishMeta,
+      oss: publishConfig?.oss,
+    });
+    result.files = files;
+    result.doc = doc;
+
+    if (publishConfig?.feishu) {
+      try {
+        await notifyFeishuSuccess(publishConfig, publishCtx(), files);
+      } catch (err) {
+        result.publishError = err instanceof Error ? err.message : String(err);
+      }
+    }
+  } catch (err) {
+    result.status = "failed";
+    result.error = err instanceof Error ? err.message : String(err);
+    result.doc = doc;
+    log.error(`job ${jobId} failed: ${result.error}`);
+    if (publishConfig?.feishu) {
+      await notifyGenerationFailure(publishConfig, publishCtx(), result.error).catch(() => {});
+    }
+  }
+
+  log.info(`job ${jobId} ${result.status} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+  return result;
+}

+ 7 - 3
packages/core/src/index.ts

@@ -1,10 +1,14 @@
-export { runPipeline } from "./pipeline.js";
-export type { PipelineConfig, PipelineCallbacks } from "./pipeline.js";
+export { runDocument } from "./document.js";
+export type {
+  DocumentRunInput,
+  DocumentRunConfig,
+  DocumentRunCallbacks,
+  DocumentRunResult,
+} from "./document.js";
 export { resolvePublishConfig } from "./publish/index.js";
 export { resolvePublishConfig } from "./publish/index.js";
 export type {
 export type {
   PublishConfig,
   PublishConfig,
   PublishContext,
   PublishContext,
-  PublishOutcome,
   OssConfig,
   OssConfig,
   FeishuConfig,
   FeishuConfig,
 } from "./publish/index.js";
 } from "./publish/index.js";

+ 0 - 275
packages/core/src/pipeline.ts

@@ -1,275 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { mkdir } from "node:fs/promises";
-import { join } from "node:path";
-import type {
-  PipelineInput,
-  PipelineJob,
-  ExportFile,
-} from "@pipeline/shared";
-import { PLATFORM_PRESETS } from "@pipeline/shared";
-import { createLogger } from "@pipeline/shared/node";
-import { parseText } from "./stages/parse.js";
-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, type PublishTargetConfig } from "./stages/export.js";
-import { cleanupExpiredOutput } from "./cleanup.js";
-import {
-  runPublish,
-  notifyGenerationFailure,
-  type PublishConfig,
-} from "./publish/index.js";
-
-export interface PipelineConfig {
-  branding: {
-    channelName: string;
-  };
-  llm: {
-    baseURL?: string;
-    apiKey?: string;
-    model: string;
-  };
-  tts: {
-    provider: string;
-    voiceId?: string;
-    model?: string;
-    format?: "mp3" | "wav" | "pcm";
-    speed?: number;
-  };
-  alignment?: {
-    provider: "whisper" | "native";
-    whisperModel?: string;
-    language?: string;
-  };
-  output: {
-    dir: string;
-    /** Auto-clean cached outputs older than this many days (0 disables). */
-    retentionDays?: number;
-  };
-  /** 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: {
-    root: string;
-    inputDir: string;
-  };
-  templates: {
-    entryPoint: string;
-  };
-  skipTts?: boolean;
-  skipLlm?: boolean;
-}
-
-export interface PipelineCallbacks {
-  onStageStart?: (stage: string) => void;
-  onStageComplete?: (stage: string) => void;
-  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,
-  callbacks?: PipelineCallbacks
-): Promise<PipelineJob> {
-  const jobId = randomUUID();
-  const log = createLogger("pipeline");
-  const t0 = Date.now();
-  const workDir = join(config.output.dir, "tmp", jobId);
-  await mkdir(workDir, { recursive: true });
-
-  // Opportunistic cache sweep: prune outputs older than retentionDays.
-  // Runs on every job (service jobs are low-frequency); never throws.
-  if (config.output.retentionDays && config.output.retentionDays > 0) {
-    const cleaned = await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
-    log.debug(
-      `cleanup scanned=${cleaned.scanned} removed=${cleaned.removed} freed=${Math.round(cleaned.bytesFreed / 1024 / 1024)}MB`
-    );
-  }
-
-  const platforms = input.platforms;
-  const job: PipelineJob = {
-    id: jobId,
-    input,
-    status: "running",
-    createdAt: Date.now(),
-    updatedAt: Date.now(),
-  };
-
-  log.info(
-    `job ${jobId} start template=${input.template} platforms=${platforms.join(",")} ` +
-      `chars=${input.text.length} skipLlm=${!!config.skipLlm} skipTts=${!!config.skipTts}`
-  );
-
-  try {
-    // Stage 1: Parse (shared across all platforms)
-    callbacks?.onStageStart?.("parse");
-    const tParse = Date.now();
-    const parsed = await parseText(input.text, input.template, {
-      llm: config.llm,
-      skipLlm: config.skipLlm,
-      source: input.source,
-    });
-    job.parsed = parsed;
-    log.info(`parse ok scenes=${parsed.scenes?.length ?? 0} (${Date.now() - tParse}ms)`);
-    callbacks?.onStageComplete?.("parse");
-
-    // Stage 2: TTS (shared across all platforms)
-    callbacks?.onStageStart?.("tts");
-    const tTts = Date.now();
-    const tts = await generateTTS(parsed, workDir, {
-      provider: config.tts.provider,
-      voiceId: config.tts.voiceId || input.voiceId,
-      model: config.tts.model,
-      format: config.tts.format,
-      speed: config.tts.speed,
-      skip: config.skipTts,
-      alignment: config.alignment,
-    });
-    job.tts = tts;
-    log.info(
-      `tts ok provider=${config.tts.provider} scenes=${tts.scenes?.length ?? 0} ` +
-        `duration=${(tts.totalDurationSeconds ?? 0).toFixed(1)}s (${Date.now() - tTts}ms)`
-    );
-    callbacks?.onStageComplete?.("tts");
-
-    // Stages 3-6: Per-platform loop
-    const allExportFiles: ExportFile[] = [];
-    job.composed = {};
-
-    for (const platform of platforms) {
-      // Stage 3: Assets
-      callbacks?.onStageStart?.(`assets:${platform}`);
-      const assets = await resolveAssets(
-        parsed, workDir, config.assets.root, config.assets.inputDir,
-        { template: input.template, aspect: PLATFORM_PRESETS[platform].aspect }
-      );
-      callbacks?.onStageComplete?.(`assets:${platform}`);
-
-      // Stage 4: Compose
-      callbacks?.onStageStart?.(`compose:${platform}`);
-      const composed = composeProject(parsed, tts, assets, platform, input.template, config.branding.channelName);
-      job.composed[platform] = composed;
-      callbacks?.onStageComplete?.(`compose:${platform}`);
-
-      // Stage 5: Render
-      callbacks?.onStageStart?.(`render:${platform}`);
-      const renderOutput = join(workDir, `render-${platform}.mp4`);
-      const tRender = Date.now();
-      await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
-      log.info(`render ${platform} ok -> ${renderOutput} (${Date.now() - tRender}ms)`);
-      callbacks?.onStageComplete?.(`render:${platform}`);
-
-      // Stage 6: Export
-      callbacks?.onStageStart?.(`export:${platform}`);
-      const exported = await exportVideo(
-        renderOutput,
-        composed,
-        config.output.dir,
-        jobId,
-        config.publishMeta?.[platform]?.[input.template]
-      );
-      const expFile = exported.files[0];
-      log.info(
-        `export ${platform} -> ${expFile?.filePath} ` +
-          `(${((expFile?.fileSizeBytes ?? 0) / 1024 / 1024).toFixed(1)}MB)`
-      );
-      allExportFiles.push(...exported.files);
-      callbacks?.onStageComplete?.(`export:${platform}`);
-    }
-
-    job.exported = { jobId, createdAt: Date.now(), files: allExportFiles };
-    job.status = "completed";
-
-    // Stage 7: Publish — upload to OSS + notify Feishu. Opt-in via config.
-    if (!config.skipPublish && config.publish) {
-      log.info(
-        `publish oss=${config.publish.oss ? "on" : "off"} feishu=${config.publish.feishu ? "on" : "off"}`
-      );
-      callbacks?.onStageStart?.("publish");
-      const publishCtx = {
-        jobId,
-        template: input.template,
-        platforms,
-        titleSuggestion: buildTitleSuggestion(
-          parsed.title,
-          parsed.scenes[0]?.trendSummary
-        ),
-      };
-      try {
-        const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
-        const urlByPath = new Map(outcome.uploaded.map((u) => [u.filePath, u.ossUrl]));
-        job.exported = {
-          ...job.exported,
-          files: allExportFiles.map((f) => ({
-            ...f,
-            ossUrl: urlByPath.get(f.filePath),
-          })),
-        };
-        if (!outcome.ok && outcome.error) {
-          job.publishError = outcome.error;
-          log.error(`publish failed: ${outcome.error}`);
-          callbacks?.onError?.("publish", new Error(outcome.error));
-        }
-        callbacks?.onStageComplete?.("publish");
-      } catch (err) {
-        // Publish must never mask a successful render.
-        const msg = err instanceof Error ? err.message : String(err);
-        job.publishError = msg;
-        log.error(`publish error: ${msg}`);
-        callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
-      }
-    }
-  } catch (err) {
-    job.status = "failed";
-    job.error = err instanceof Error ? err.message : String(err);
-    log.error(`job ${jobId} failed: ${job.error}`);
-    callbacks?.onError?.("pipeline", err instanceof Error ? err : new Error(String(err)));
-
-    // Render failed before any file existed — push failure info to Feishu.
-    if (!config.skipPublish && config.publish) {
-      await notifyGenerationFailure(
-        config.publish,
-        {
-          jobId,
-          template: input.template,
-          platforms,
-          titleSuggestion: buildTitleSuggestion(
-            job.parsed?.title,
-            job.parsed?.scenes?.[0]?.trendSummary
-          ),
-        },
-        job.error ?? "unknown error"
-      ).catch(() => {});
-    }
-  }
-
-  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
-  log.info(
-    `job ${jobId} ${job.status} in ${elapsed}s` +
-      (job.publishError ? ` publishError=${job.publishError.slice(0, 160)}` : "")
-  );
-  job.updatedAt = Date.now();
-  return job;
-}

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

@@ -1,11 +1,23 @@
-import { isoDateString, type ExportFile } from "@pipeline/shared";
-import { getTimezone, createLogger, describeError } from "@pipeline/shared/node";
-import { uploadToOss, type OssConfig } from "./oss.js";
+import type { ExportFile } from "@pipeline/shared";
+import { createLogger, describeError } from "@pipeline/shared/node";
 import { sendFeishuMessage, type FeishuConfig } from "./feishu.js";
 import { sendFeishuMessage, type FeishuConfig } from "./feishu.js";
 
 
 const log = createLogger("publish");
 const log = createLogger("publish");
 
 
-export type { OssConfig, FeishuConfig };
+/** Alibaba Cloud OSS config (secrets via env). Structurally identical to the
+ *  renderer's OssConfig — the orchestrator passes publish.oss through to it. */
+export interface OssConfig {
+  region: string;
+  bucket: string;
+  accessKeyId: string;
+  accessKeySecret: string;
+  endpoint?: string;
+  path?: string;
+  publicDomain?: string;
+  secure?: boolean;
+}
+
+export type { FeishuConfig } from "./feishu.js";
 
 
 export interface PublishConfig {
 export interface PublishConfig {
   oss?: OssConfig;
   oss?: OssConfig;
@@ -16,36 +28,16 @@ export interface PublishContext {
   jobId: string;
   jobId: string;
   template: string;
   template: string;
   platforms: 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).
-   */
+  /** Suggested video title for the Feishu message, e.g.
+   *  "GitHub 每日热榜|AI Agent 框架持续火热". */
   titleSuggestion?: string;
   titleSuggestion?: string;
 }
 }
 
 
-export interface UploadedFile {
-  platform: string;
-  filePath: string;
-  ossUrl: string;
-}
-
-export interface PublishOutcome {
-  /** true when there is no OSS configured, or every file uploaded successfully. */
-  ok: boolean;
-  uploaded: UploadedFile[];
-  /** Present when an upload failed (the render itself still succeeded). */
-  error?: string;
-}
-
 function parseBool(value: string | undefined, fallback: boolean): boolean {
 function parseBool(value: string | undefined, fallback: boolean): boolean {
   if (value === undefined || value === "") return fallback;
   if (value === undefined || value === "") return fallback;
   return value === "1" || value.toLowerCase() === "true";
   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[] {
 function parseOpenIdList(value: unknown): string[] {
   if (!value) return [];
   if (!value) return [];
   const arr = Array.isArray(value) ? value : String(value).split(",");
   const arr = Array.isArray(value) ? value : String(value).split(",");
@@ -55,7 +47,8 @@ function parseOpenIdList(value: unknown): string[] {
 /**
 /**
  * Build a PublishConfig from the raw YAML config merged with environment
  * Build a PublishConfig from the raw YAML config merged with environment
  * variables. Env vars take precedence and are the recommended place for secrets.
  * variables. Env vars take precedence and are the recommended place for secrets.
- * Returns undefined when neither OSS nor Feishu is usable.
+ * Returns undefined when neither OSS nor Feishu is usable. (OSS upload itself
+ * happens in the renderer module; this just resolves the config.)
  */
  */
 export function resolvePublishConfig(
 export function resolvePublishConfig(
   raw?: Record<string, any> | null
   raw?: Record<string, any> | null
@@ -63,8 +56,7 @@ export function resolvePublishConfig(
   const ossRaw = raw?.oss ?? {};
   const ossRaw = raw?.oss ?? {};
 
 
   const accessKeyId = process.env.OSS_ACCESS_KEY_ID || ossRaw.accessKeyId || "";
   const accessKeyId = process.env.OSS_ACCESS_KEY_ID || ossRaw.accessKeyId || "";
-  const accessKeySecret =
-    process.env.OSS_ACCESS_KEY_SECRET || ossRaw.accessKeySecret || "";
+  const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || ossRaw.accessKeySecret || "";
   const region = process.env.OSS_REGION || ossRaw.region || "";
   const region = process.env.OSS_REGION || ossRaw.region || "";
   const bucket = process.env.OSS_BUCKET || ossRaw.bucket || "";
   const bucket = process.env.OSS_BUCKET || ossRaw.bucket || "";
 
 
@@ -102,22 +94,18 @@ async function safeNotify(config: FeishuConfig, text: string): Promise<boolean>
     await sendFeishuMessage(config, text);
     await sendFeishuMessage(config, text);
     return true;
     return true;
   } catch (err) {
   } catch (err) {
-    // Notification is best-effort (must not mask the real outcome), but log it
-    // so a misconfigured webhook / signing failure / network error is visible
-    // (including the fetch cause: DNS / connection / TLS) instead of silent.
     log.error(`feishu send failed: ${describeError(err)}`);
     log.error(`feishu send failed: ${describeError(err)}`);
     return false;
     return false;
   }
   }
 }
 }
 
 
-/** "标题建议: …" line, or null when no title is known (so it can be filtered out). */
 function titleLine(ctx: PublishContext): string | null {
 function titleLine(ctx: PublishContext): string | null {
   return ctx.titleSuggestion ? `标题建议: ${ctx.titleSuggestion}` : null;
   return ctx.titleSuggestion ? `标题建议: ${ctx.titleSuggestion}` : null;
 }
 }
 
 
 function buildSuccessText(
 function buildSuccessText(
   ctx: PublishContext,
   ctx: PublishContext,
-  uploaded: UploadedFile[],
+  uploaded: Array<{ platform: string; ossUrl: string }>,
   files: ExportFile[]
   files: ExportFile[]
 ): string {
 ): string {
   const lines = uploaded.length
   const lines = uploaded.length
@@ -136,24 +124,6 @@ function buildSuccessText(
     .join("\n");
     .join("\n");
 }
 }
 
 
-function buildUploadFailureText(
-  ctx: PublishContext,
-  files: ExportFile[],
-  error: string
-): string {
-  return [
-    "⚠️ 视频已生成,但 OSS 上传失败",
-    titleLine(ctx),
-    `模板: ${ctx.template}`,
-    `平台: ${ctx.platforms.join(", ")}`,
-    `任务: ${ctx.jobId}`,
-    `本地文件: ${files.map((f) => f.filePath).join(", ")}`,
-    `错误: ${error}`,
-  ]
-    .filter(Boolean)
-    .join("\n");
-}
-
 function buildGenerationFailureText(ctx: PublishContext, error: string): string {
 function buildGenerationFailureText(ctx: PublishContext, error: string): string {
   return [
   return [
     "❌ 视频生成失败",
     "❌ 视频生成失败",
@@ -168,44 +138,20 @@ function buildGenerationFailureText(ctx: PublishContext, error: string): string
 }
 }
 
 
 /**
 /**
- * Upload all exported files to OSS (if configured) and notify Feishu.
- * - Upload success  → push OSS resource links.
- * - Upload failure   → push failure info (render still succeeded locally).
- * - No OSS configured → push success with local file paths.
+ * Feishu success notify. The renderer uploads to OSS and fills `ossUrl` on each
+ * file; this pushes the success message (OSS links when present, else local
+ * paths). Best-effort, never throws.
  */
  */
-export async function runPublish(
-  files: ExportFile[],
+export async function notifyFeishuSuccess(
   publish: PublishConfig,
   publish: PublishConfig,
-  ctx: PublishContext
-): Promise<PublishOutcome> {
-  const dateDir = isoDateString(new Date(), getTimezone());
-  const uploaded: UploadedFile[] = [];
-
-  if (publish.oss) {
-    log.info(`oss upload start files=${files.length} prefix=${publish.oss.path ?? "(root)"}`);
-    try {
-      for (const file of files) {
-        const result = await uploadToOss(file.filePath, publish.oss, { dateDir });
-        uploaded.push({ platform: file.platform, filePath: file.filePath, ossUrl: result.url });
-        log.info(`oss uploaded [${file.platform}] key=${result.key} url=${result.url}`);
-      }
-    } catch (err) {
-      const error = describeError(err);
-      log.error(`oss upload failed: ${error}`);
-      if (publish.feishu) {
-        const ok = await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error));
-        log.info(`feishu upload-failure notice ${ok ? "sent" : "failed"}`);
-      }
-      return { ok: false, uploaded, error };
-    }
-  }
-
-  if (publish.feishu) {
-    const ok = await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
-    log.info(`feishu success notice ${ok ? "sent" : "failed"}`);
-  }
-
-  return { ok: true, uploaded };
+  ctx: PublishContext,
+  files: ExportFile[]
+): Promise<boolean> {
+  if (!publish.feishu) return false;
+  const uploaded = files
+    .filter((f) => f.ossUrl)
+    .map((f) => ({ platform: f.platform, ossUrl: f.ossUrl! }));
+  return safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
 }
 }
 
 
 /** Notify Feishu that the render itself failed before any file was produced. */
 /** Notify Feishu that the render itself failed before any file was produced. */

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

@@ -1,95 +0,0 @@
-import type {
-  ParsedContent,
-  TTSStageResult,
-  AssetManifest,
-  ComposedProject,
-  ComposedScene,
-  PlatformPreset,
-  TemplateType,
-} from "@pipeline/shared";
-import { PLATFORM_PRESETS } from "@pipeline/shared";
-
-export function composeProject(
-  parsed: ParsedContent,
-  tts: TTSStageResult,
-  assets: AssetManifest,
-  platform: PlatformPreset,
-  template: TemplateType,
-  channelName: string = "Pipeline"
-): ComposedProject {
-  const preset = PLATFORM_PRESETS[platform];
-  const fps = preset.fps;
-
-  const scenes: ComposedScene[] = parsed.scenes.map((scene, i) => {
-    const ttsScene = tts.scenes.find((s) => s.sceneId === scene.id);
-    const durationSeconds = ttsScene?.durationSeconds ?? scene.duration ?? 5;
-    const startFrame = Math.round((ttsScene?.startOffsetSeconds ?? 0) * fps);
-    const endFrame = startFrame + Math.round(durationSeconds * fps);
-
-    const backgroundAsset = assets.backgrounds[i]
-      ? { id: assets.backgrounds[i].id, localPath: assets.backgrounds[i].localPath }
-      : undefined;
-
-    // wordTimestamps are already scene-relative (0-based) from the TTS stage
-    const wordTimestamps = ttsScene?.wordTimestamps ?? [];
-
-    return {
-      id: scene.id,
-      sceneType: scene.sceneType,
-      startFrame,
-      endFrame,
-      narration: scene.narration,
-      displayText: scene.displayText,
-      title: scene.title,
-      wordTimestamps,
-      audioPath: ttsScene?.audioFilePath || undefined,
-      keyframes: scene.keyframes.map((kf) => ({
-        type: kf.type,
-        content: kf.content,
-        startFrame: kf.timeOffset ? Math.round(kf.timeOffset * fps) : undefined,
-        endFrame: undefined,
-        style: kf.style,
-      })),
-      images: scene.images
-        ?.map((img) => {
-          const resolved = assets.images.find(
-            (a) =>
-              (img.path && a.path === img.path) ||
-              (img.url && a.url === img.url) ||
-              (img.query && a.query === img.query) ||
-              (img.repoSocialPreview && a.repoSocialPreview === img.repoSocialPreview)
-          );
-          return resolved
-            ? { url: img.url, query: img.query, localPath: resolved.localPath }
-            : undefined;
-        })
-        .filter((x): x is NonNullable<typeof x> => !!x),
-      backgroundAsset,
-      layoutHint: scene.layoutHint ?? "centered",
-      github: scene.github,
-      coverTags: scene.coverTags,
-      trendSummary: scene.trendSummary,
-    };
-  });
-
-  const totalFrames =
-    scenes.length > 0 ? scenes[scenes.length - 1].endFrame : 90;
-  const aspectKey = preset.aspect === "16:9" ? "landscape" : "portrait";
-
-  return {
-    compositionId: `${template}-${aspectKey}`,
-    width: preset.width,
-    height: preset.height,
-    fps,
-    durationInFrames: totalFrames,
-    audioPath: tts.audioFilePath,
-    scenes,
-    template,
-    platform,
-    title: parsed.title,
-    subtitle: parsed.subtitle,
-    outro: parsed.outro,
-    channelName,
-    publish: parsed.publish,
-  };
-}

+ 0 - 109
packages/core/src/stages/export.ts

@@ -1,109 +0,0 @@
-import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
-import { isoDateString } from "@pipeline/shared";
-import { getTimezone, createLogger } from "@pipeline/shared/node";
-import { join } from "node:path";
-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,
-  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
-  // shown on the cover, not UTC.
-  const subDir = join(outputDir, project.template, isoDateString(new Date(), getTimezone()));
-  await mkdir(subDir, { recursive: true });
-
-  const { stat, copyFile } = await import("node:fs/promises");
-  const statResult = await stat(renderedPath);
-
-  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,
-      filePath: finalPath,
-      fileSizeBytes: statResult.size,
-      width: project.width,
-      height: project.height,
-      durationSeconds: project.durationInFrames / project.fps,
-      format: "mp4",
-      codec: "h264",
-    },
-  ];
-
-  return {
-    jobId,
-    createdAt: Date.now(),
-    files,
-  };
-}

+ 0 - 393
packages/core/src/stages/parse.ts

@@ -1,393 +0,0 @@
-import {
-  VideoInputSchema,
-  ParsedContentSchema,
-  detectInputFormat,
-  LLMClient,
-  getParsePrompt,
-  stripUnsupportedGlyphs,
-  clipToLength,
-  formatChineseDate,
-  normalizeCountsForTTS,
-} from "@pipeline/shared";
-import { getTimezone } from "@pipeline/shared/node";
-import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
-
-/** Appended on the retry attempt when the first LLM JSON failed to parse.
- *  With response_format=json_object a parse failure almost always means the
- *  response was truncated by the output length limit (finish_reason="length").
- *  This nudges the model to produce a shorter, fully-closed JSON that fits. */
-const JSON_TRUNCATION_NUDGE =
-  "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
-
-export interface ParseStageConfig {
-  llm: {
-    baseURL?: string;
-    apiKey?: string;
-    model: string;
-  };
-  skipLlm?: boolean;
-  source?: string;
-}
-
-/**
- * Clip LLM-generated text fields to their schema-enforced maximums before
- * validation. Clips at a sentence/clause boundary when possible (see
- * clipToLength) so the result still reads naturally. Mutates a copy and
- * returns it; the input is left untouched.
- *
- * When `template === "github-trending"`, also strips leading greetings from
- * 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;
-  const root = (input as any).scenes && Array.isArray((input as any).scenes)
-    ? { ...(input as any) }
-    : input;
-  if (!Array.isArray((root as any).scenes)) return input;
-
-  (root as any).scenes = (root as any).scenes.map((scene: any) => {
-    if (!scene || typeof scene !== "object") return scene;
-    const next: any = { ...scene };
-    const narration = template === "github-trending"
-      ? stripLeadingGreeting(scene.narration)
-      : scene.narration;
-    next.narration = clipToLength(narration, 200);
-    if (scene.github && typeof scene.github === "object") {
-      next.github = {
-        ...scene.github,
-        highlights: clipToLength(scene.github.highlights, 30),
-        intro: clipToLength(scene.github.intro, 200),
-        review: clipToLength(scene.github.review, 30),
-      };
-    }
-    return next;
-  });
-
-  // github-trending: clip the cover-scene metadata the LLM emits at the top
-  // level (coverTags ≤5, each ≤12 chars; trendSummary ≤40). Same drift
-  // tolerance as the per-scene clipping above.
-  if (template === "github-trending") {
-    if (Array.isArray((root as any).coverTags)) {
-      (root as any).coverTags = (root as any).coverTags
-        .filter((t: any) => typeof t === "string" && t.trim())
-        .map((t: any) => t.trim().slice(0, 12))
-        .slice(0, 5);
-    }
-    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;
-}
-
-/** Common Chinese greeting prefixes that open a narration. Used to dedup
- *  greetings when the cover scene already provides one. */
-const GREETING_PATTERNS = [
-  /^[大各]位(?:好|大大|朋友们)?[,,!!\s]+/,
-  /^大家(?:好|朋友们)?[,,!!\s]+/,
-  /^哈喽[,,!!\s]+/,
-  /^嗨[,,!!\s]+/,
-  /^早(?:上)?好[,,!!\s]+/,
-  /^下(?:午)?好[,,!!\s]+/,
-  /^晚(?:上)?好[,,!!\s]+/,
-];
-
-/** Show-opener clauses that reference the day / show / format instead of the
- *  project. e.g. "今天是GitHub热榜速览,..." or "今天为大家带来..." — these
- *  duplicate the cover scene's framing and must be stripped from content
- *  narrations. Each pattern removes one comma-delimited leading clause. */
-const META_OPENER_PATTERNS = [
-  // 今天 / 本期 / 本周 / 本次 + a show-context noun + clause boundary
-  /^(?:今天|本(?:周|期|次))[^,,。!!.]*?(?:热榜|速览|榜单|节目|频道|播报|速递|盘点|精选|专栏|特辑)[^,,。!!.]*?[,,。!!.]\s*/,
-  // "今天[为给]大家[带介推带聊]..." — host-style lead-in to a list of items
-  /^今天[为给][^,,。!!.]*?[,,。!!.]\s*/,
-];
-
-function stripLeadingGreeting(s: string | undefined): string | undefined {
-  if (typeof s !== "string" || s.length === 0) return s;
-  let out = s;
-  // Strip simple greetings first (may reveal a meta-opener that follows).
-  for (const re of GREETING_PATTERNS) {
-    out = out.replace(re, "");
-  }
-  // Then strip show-opener clauses. Run a few passes so back-to-back
-  // meta clauses ("今天为大家带来几个项目,首先要聊的是 React") collapse fully.
-  for (let i = 0; i < 3; i++) {
-    let next = out;
-    for (const re of META_OPENER_PATTERNS) {
-      next = next.replace(re, "");
-    }
-    if (next === out) break;
-    out = next;
-  }
-  return out;
-}
-
-/** Strip emoji/decorative glyphs from every visible-text field of a VideoInput. */
-function sanitizeVideoInput(input: VideoInput): VideoInput {
-  const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
-    Array.isArray(kfs)
-      ? kfs.map((kf) => ({ ...kf, content: stripUnsupportedGlyphs(kf.content ?? "") }))
-      : kfs;
-
-  return {
-    ...input,
-    title: stripUnsupportedGlyphs(input.title ?? ""),
-    subtitle: input.subtitle ? stripUnsupportedGlyphs(input.subtitle) : input.subtitle,
-    summary: input.summary ? stripUnsupportedGlyphs(input.summary) : input.summary,
-    coverTags: Array.isArray(input.coverTags)
-      ? 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,
-    scenes: input.scenes.map((s) => ({
-      ...s,
-      title: s.title ? stripUnsupportedGlyphs(s.title) : s.title,
-      narration: stripUnsupportedGlyphs(s.narration ?? ""),
-      displayText: s.displayText ? stripUnsupportedGlyphs(s.displayText) : s.displayText,
-      keyframes: cleanKeyframes(s.keyframes),
-    })),
-    outro: input.outro
-      ? {
-          ...input.outro,
-          text: stripUnsupportedGlyphs(input.outro.text ?? ""),
-          narration: input.outro.narration
-            ? stripUnsupportedGlyphs(input.outro.narration)
-            : input.outro.narration,
-          cta: input.outro.cta ? stripUnsupportedGlyphs(input.outro.cta) : input.outro.cta,
-        }
-      : input.outro,
-  };
-}
-
-export async function parseText(
-  text: string,
-  template: string,
-  config: ParseStageConfig
-): Promise<ParsedContent> {
-  // --- Input normalization ---
-  const detected = detectInputFormat(text);
-
-  let videoInput: VideoInput;
-
-  if (detected.format === "valid-schema") {
-    videoInput = sanitizeVideoInput(detected.parsed!);
-  } else if (
-    config.skipLlm ||
-    !(config.llm.apiKey || process.env.OPENAI_API_KEY)
-  ) {
-    throw new Error(
-      `Input is not valid VideoInputSchema JSON and AI processing is disabled. ` +
-        `Provide structured JSON matching VideoInputSchema or enable LLM processing (set OPENAI_API_KEY or remove --skip-llm).`
-    );
-  } else {
-    const client = new LLMClient(config.llm);
-    const systemPrompt = getParsePrompt(
-      template as "news" | "knowledge" | "opinion" | "marketing" | "github-trending",
-      config.source
-    );
-
-    // Strip markdown code block wrapper if present (```json ... ```).
-    const stripFences = (s: string) =>
-      s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
-
-    // With response_format=json_object the model emits valid JSON syntax, so a
-    // parse failure almost always means the response was truncated by the
-    // output length limit (finish_reason="length") — common for github-trending
-    // when the trending list is large. Retry once with a conciseness nudge so
-    // the (shorter) JSON completes within the budget.
-    let aiParsed: unknown;
-    let lastFinish: string | null = null;
-    let lastRaw = "";
-    for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
-      const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
-      const { content, finishReason } = await client.chat(systemPrompt, userMessage);
-      lastFinish = finishReason;
-      lastRaw = stripFences(content);
-      try {
-        aiParsed = JSON.parse(lastRaw);
-      } catch {
-        // not valid JSON yet — fall through to retry, or to the final error below
-      }
-    }
-    if (aiParsed === undefined) {
-      throw new Error(
-        `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
-          `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
-      );
-    }
-
-    // LLMs routinely overshoot the documented character budgets (narration
-    // ≤200; github highlights/intro/review ≤30/200/30). Clip in place before
-    // schema validation so the pipeline tolerates drift instead of hard-failing.
-    aiParsed = applyLengthLimits(aiParsed, template);
-
-    const validationResult = VideoInputSchema.safeParse(aiParsed);
-    if (!validationResult.success) {
-      throw new Error(
-        `AI output does not match VideoInputSchema: ${validationResult.error.message}`
-      );
-    }
-    videoInput = sanitizeVideoInput(validationResult.data);
-  }
-  // --- End input normalization ---
-
-  const scenes: Scene[] = [];
-  let sceneIndex = 0;
-
-  // For 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 regardless of input.
-  if (template === "github-trending") {
-    videoInput = {
-      ...videoInput,
-      title: "GitHub 每日热榜",
-      subtitle: formatChineseDate(new Date(), getTimezone()),
-    };
-  }
-
-  // Cover scene — always present. When the user/LLM provided explicit cover
-  // content, use it. Otherwise derive the cover from the first content scene
-  // (its keyframes as a preview list, its first image as the background) so
-  // the cover reflects what the video is actually about. Default 1s so it
-  // flashes briefly without pushing back the real content.
-  const coverInput = videoInput.cover;
-  const firstScene = videoInput.scenes[0];
-  // For github-trending the cover is a clean title screen — it should not
-  // preview or hint at later repo scenes (no inherited keyframes, no inherited
-  // image). Other templates keep the original "inherit from first scene"
-  // behaviour so the cover reflects the video's actual topic.
-  const inheritFromFirstScene = template !== "github-trending";
-  const coverKeyframes = inheritFromFirstScene
-    ? (coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3))
-    : (coverInput?.keyframes ?? []);
-  const firstSceneCoverImage = inheritFromFirstScene
-    ? (firstScene?.images ?? []).find((img) => img.path || img.url || img.query)
-    : undefined;
-  const coverImages = coverInput
-    ? [
-        ...(coverInput.imagePath ? [{ path: coverInput.imagePath }] : []),
-        ...(coverInput.imageUrl ? [{ url: coverInput.imageUrl }] : []),
-        ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
-      ]
-    : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
-  // For github-trending the cover doubles as the opening narration screen. The
-  // narration is a short bridge into the repo details — the date and repo count
-  // are shown on screen (subtitle pill + the cards), NOT read aloud. It also
-  // carries the LLM-produced trendSummary (rendered as a subtitle line by the
-  // template); the repo list itself is rendered from the content scenes. Other
-  // templates keep the original behaviour (1s silent cover).
-  // github-trending covers only the TOP 6 repos by today's star gain (matching
-  // the cover preview), played in descending-gain order so playback follows the
-  // cover ranking. Other templates keep all input scenes.
-  const contentInputs = template === "github-trending"
-    ? videoInput.scenes
-        .filter(s => s.github)
-        .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",
-    index: sceneIndex++,
-    sceneType: "cover",
-    narration: coverNarration,
-    title: videoInput.title,
-    keyframes: coverKeyframes,
-    images: coverImages,
-    backgroundQuery: coverInput?.imageQuery,
-    trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
-    duration: 1,
-  });
-
-  // Content scenes
-  for (const s of contentInputs) {
-    scenes.push({
-      id: s.id,
-      index: sceneIndex++,
-      sceneType: "content",
-      narration: s.narration,
-      displayText: s.displayText,
-      title: s.title,
-      keyframes: s.keyframes ?? [],
-      images: s.images,
-      duration: s.duration,
-      speed: s.speed,
-      layoutHint: s.layoutHint,
-      github: s.github,
-    });
-  }
-
-  // Outro scene (optional)
-  if (videoInput.outro) {
-    scenes.push({
-      id: "outro",
-      index: sceneIndex++,
-      sceneType: "outro",
-      narration: videoInput.outro.narration || videoInput.outro.text,
-      title: videoInput.outro.cta,
-      keyframes: [],
-    });
-  }
-
-  // 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,
-    summary: videoInput.summary || "",
-    cover: videoInput.cover,
-    scenes,
-    outro: videoInput.outro,
-    globalStyle: videoInput.globalStyle || { tone: "formal", pace: "normal" },
-    publish: videoInput.publish,
-  };
-
-  return ParsedContentSchema.parse(result);
-}

+ 0 - 130
packages/core/src/stages/render.ts

@@ -1,130 +0,0 @@
-import { basename, dirname, join } from "node:path";
-import { copyFile, mkdir } from "node:fs/promises";
-import { existsSync } from "node:fs";
-import type { ComposedProject } from "@pipeline/shared";
-import { renderMedia, getCompositions } from "@remotion/renderer";
-import { bundle } from "@remotion/bundler";
-
-export interface RenderResult {
-  outputPath: string;
-  fileSizeBytes: number;
-}
-
-async function preparePublicDir(project: ComposedProject, assetsRoot: string): Promise<string> {
-  const publicDir = join(dirname(project.audioPath), "public");
-  await mkdir(publicDir, { recursive: true });
-
-  // Copy per-scene audio files
-  for (const scene of project.scenes) {
-    if (scene.audioPath) {
-      const dest = join(publicDir, basename(scene.audioPath));
-      try { await copyFile(scene.audioPath, dest); } catch {}
-    }
-  }
-
-  // Copy background images and scene images
-  for (const scene of project.scenes) {
-    if (scene.backgroundAsset?.localPath) {
-      const dest = join(publicDir, basename(scene.backgroundAsset.localPath));
-      try { await copyFile(scene.backgroundAsset.localPath, dest); } catch {}
-    }
-    if (scene.images) {
-      for (const img of scene.images) {
-        const dest = join(publicDir, basename(img.localPath));
-        try { await copyFile(img.localPath, dest); } catch {}
-      }
-    }
-  }
-
-  // Copy bundled fonts so templates can load them via staticFile()
-  const fontsDir = join(assetsRoot, "fonts");
-  if (existsSync(fontsDir)) {
-    const fontsDest = join(publicDir, "fonts");
-    await mkdir(fontsDest, { recursive: true });
-    for (const f of ["NotoSansSC-Regular.ttf", "NotoSansSC-Bold.ttf"]) {
-      const src = join(fontsDir, f);
-      if (existsSync(src)) {
-        await copyFile(src, join(fontsDest, f));
-      } else {
-        console.warn(`[render] font source missing: ${src}`);
-      }
-    }
-  } else {
-    console.warn(`[render] assets fonts dir missing: ${fontsDir}`);
-  }
-
-  return publicDir;
-}
-
-function buildInputProps(project: ComposedProject) {
-  return {
-    scenes: project.scenes.map((scene) => ({
-      ...scene,
-      audioFilename: scene.audioPath ? basename(scene.audioPath) : "",
-      backgroundAsset: scene.backgroundAsset
-        ? { id: scene.backgroundAsset.id, filename: basename(scene.backgroundAsset.localPath) }
-        : undefined,
-      images: scene.images?.map((img) => ({
-        ...img,
-        filename: basename(img.localPath),
-      })),
-    })),
-    template: project.template,
-    platform: project.platform,
-    title: project.title || "",
-    subtitle: project.subtitle || "",
-    outro: project.outro,
-    channelName: project.channelName,
-    globalStyle: { tone: "formal", pace: "normal" },
-  };
-}
-
-export async function renderVideo(
-  project: ComposedProject,
-  outputPath: string,
-  templatesEntry: string,
-  assetsRoot: string
-): Promise<RenderResult> {
-  const publicDir = await preparePublicDir(project, assetsRoot);
-  const inputProps = buildInputProps(project);
-
-  const bundleLocation = await bundle({
-    entryPoint: templatesEntry,
-    publicDir,
-  });
-
-  const compositions = await getCompositions(bundleLocation, {
-    inputProps,
-  });
-  const composition = compositions.find(
-    (c) => c.id === project.compositionId
-  );
-
-  if (!composition) {
-    const available = compositions.map((c) => c.id).join(", ");
-    throw new Error(
-      `Composition "${project.compositionId}" not found. Available: ${available}`
-    );
-  }
-
-  await renderMedia({
-    composition,
-    serveUrl: bundleLocation,
-    codec: "h264",
-    outputLocation: outputPath,
-    inputProps,
-    onProgress: ({ progress }) => {
-      if (process.stdout.isTTY) {
-        process.stdout.write(`\rRendering: ${(progress * 100).toFixed(1)}%`);
-      }
-    },
-  });
-
-  const { stat } = await import("node:fs/promises");
-  const statResult = await stat(outputPath);
-
-  return {
-    outputPath,
-    fileSizeBytes: statResult.size,
-  };
-}

+ 0 - 167
packages/core/src/stages/tts.ts

@@ -1,167 +0,0 @@
-import type { TTSStageResult, TTSSceneResult, ParsedContent } from "@pipeline/shared";
-import { getProvider, alignWithWhisper } from "@pipeline/tts";
-import { mkdir, writeFile } from "node:fs/promises";
-import { existsSync } from "node:fs";
-import { join } from "node:path";
-
-export interface TTSStageConfig {
-  provider: string;
-  voiceId?: string;
-  model?: string;
-  format?: "mp3" | "wav" | "pcm";
-  speed?: number;
-  skip?: boolean;
-  alignment?: {
-    provider: "whisper" | "native";
-    whisperModel?: string;
-    language?: string;
-  };
-}
-
-export async function generateTTS(
-  parsed: ParsedContent,
-  workDir: string,
-  config: TTSStageConfig
-): Promise<TTSStageResult> {
-  const ttsDir = join(workDir, "tts");
-  await mkdir(ttsDir, { recursive: true });
-  await mkdir(join(workDir, "audio"), { recursive: true });
-
-  if (config.skip) {
-    return generateSilentTTS(parsed, workDir);
-  }
-
-  const provider = getProvider(config.provider);
-  const globalSpeed = config.speed ?? 1.0;
-  const useWhisper = config.alignment?.provider === "whisper";
-
-  // Inject model name as env var so providers pick it up alongside their env-based config
-  if (config.model) {
-    const envMap: Record<string, string> = {
-      "openai-tts": "OPENAI_TTS_MODEL",
-      "fish-audio": "FISH_AUDIO_MODEL",
-      "minimax": "MINIMAX_MODEL",
-      "elevenlabs": "ELEVENLABS_MODEL",
-    };
-    const envKey = envMap[config.provider];
-    if (envKey && !process.env[envKey]) {
-      process.env[envKey] = config.model;
-    }
-  }
-  const sceneResults: TTSSceneResult[] = [];
-  let currentOffset = 0;
-
-  for (const scene of parsed.scenes) {
-    if (!scene.narration.trim()) {
-      // No narration (e.g. cover scene) — use duration or default 3s
-      const dur = scene.duration ?? 3;
-      sceneResults.push({
-        sceneId: scene.id,
-        audioFilePath: "",
-        durationSeconds: dur,
-        startOffsetSeconds: currentOffset,
-        wordTimestamps: [],
-      });
-      currentOffset += dur;
-      continue;
-    }
-
-    // Per-scene speed: scene.speed > global speed > 1.0
-    const sceneSpeed = scene.speed ?? globalSpeed;
-
-    const result = await provider.synthesize({
-      text: scene.narration,
-      voiceId: config.voiceId || "",
-      format: config.format || "mp3",
-      speed: sceneSpeed,
-      outputDir: ttsDir,
-      filename: scene.id,
-    });
-
-    // durationSeconds is now measured exactly from the audio file
-    const audioDuration = result.durationSeconds;
-
-    // Determine scene visual duration:
-    // - User specified duration → use it, but extend if audio is longer (can't cut mid-word)
-    // - No user duration → scene length = exact audio length
-    const sceneDuration = scene.duration
-      ? Math.max(scene.duration, audioDuration)
-      : audioDuration;
-
-    // Run whisper alignment when configured, otherwise use provider timestamps
-    let wordTimestamps = result.wordTimestamps;
-
-    if (useWhisper && result.audioFilePath) {
-      const aligned = await alignWithWhisper({
-        audioFilePath: result.audioFilePath,
-        text: scene.narration,
-        model: config.alignment?.whisperModel,
-        language: config.alignment?.language,
-      });
-      if (aligned) {
-        wordTimestamps = aligned;
-      }
-    }
-
-    sceneResults.push({
-      sceneId: scene.id,
-      audioFilePath: result.audioFilePath,
-      durationSeconds: sceneDuration,
-      startOffsetSeconds: currentOffset,
-      wordTimestamps,
-    });
-
-    currentOffset += sceneDuration;
-  }
-
-  // No longer concatenating — each scene plays its own audio independently
-  // Keep a dummy path for schema compatibility
-  const combinedPath = join(workDir, "audio", "full-narration.mp3");
-  if (!existsSync(combinedPath)) {
-    await writeFile(combinedPath, Buffer.alloc(0));
-  }
-
-  return {
-    audioFilePath: combinedPath,
-    totalDurationSeconds: currentOffset,
-    scenes: sceneResults,
-  };
-}
-
-async function generateSilentTTS(
-  parsed: ParsedContent,
-  workDir: string
-): Promise<TTSStageResult> {
-  const combinedPath = join(workDir, "audio", "full-narration.mp3");
-  const silentBytes = Buffer.alloc(16000, 0);
-  await writeFile(combinedPath, silentBytes);
-
-  const sceneResults: TTSSceneResult[] = [];
-  let currentOffset = 0;
-
-  for (const scene of parsed.scenes) {
-    const duration = scene.duration ?? 5;
-    const words = scene.narration.split(/\s+/).filter((w) => w);
-    const durationPerWord = duration / Math.max(words.length, 1);
-
-    sceneResults.push({
-      sceneId: scene.id,
-      audioFilePath: "",
-      durationSeconds: duration,
-      startOffsetSeconds: currentOffset,
-      wordTimestamps: words.map((word, i) => ({
-        word,
-        startSeconds: i * durationPerWord,
-        endSeconds: (i + 1) * durationPerWord,
-      })),
-    });
-
-    currentOffset += duration;
-  }
-
-  return {
-    audioFilePath: combinedPath,
-    totalDurationSeconds: currentOffset,
-    scenes: sceneResults,
-  };
-}

+ 3 - 2
packages/core/tsconfig.json

@@ -8,7 +8,8 @@
   "include": ["src"],
   "include": ["src"],
   "references": [
   "references": [
     { "path": "../shared" },
     { "path": "../shared" },
-    { "path": "../tts" },
-    { "path": "../templates" }
+    { "path": "../text" },
+    { "path": "../audio" },
+    { "path": "../renderer" }
   ]
   ]
 }
 }

+ 31 - 0
packages/renderer/package.json

@@ -0,0 +1,31 @@
+{
+  "name": "@pipeline/renderer",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@pipeline/shared": "workspace:*",
+    "@pipeline/templates": "workspace:*",
+    "@remotion/renderer": "^4.0.0",
+    "@remotion/bundler": "^4.0.0",
+    "ali-oss": "^6.21.0",
+    "zod": "^3.24.0",
+    "tmp-promise": "^3.0.3",
+    "yaml": "^2.7.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

+ 29 - 79
packages/core/src/stages/assets.ts → packages/renderer/src/assets.ts

@@ -1,7 +1,8 @@
-import type { ParsedContent, AssetManifest, SceneImage, TemplateType, AspectRatio } from "@pipeline/shared";
+import type { VideoDocument, AssetManifest, TemplateType, AspectRatio } from "@pipeline/shared";
 import { join, resolve, extname, basename } from "node:path";
 import { join, resolve, extname, basename } from "node:path";
 import { mkdir, writeFile, copyFile } from "node:fs/promises";
 import { mkdir, writeFile, copyFile } from "node:fs/promises";
 import { existsSync } from "node:fs";
 import { existsSync } from "node:fs";
+import { segmentImageRefs, type ResolvableImage } from "./github-images.js";
 
 
 const REPO_DETAIL_URL = "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo";
 const REPO_DETAIL_URL = "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo";
 
 
@@ -10,8 +11,15 @@ export interface AssetsConfig {
   aspect: AspectRatio;
   aspect: AspectRatio;
 }
 }
 
 
+/**
+ * Resolve all image references in a VideoDocument to local files, and pick a
+ * background per segment. Verbatim port of core/stages/assets.ts, operating on
+ * doc.data instead of parsed.scenes. Resolution priority is preserved:
+ *   local path > repoSocialPreview > url > query
+ * (repoSocialPreview beats url so an LLM-added url doesn't fetch the github HTML page.)
+ */
 export async function resolveAssets(
 export async function resolveAssets(
-  parsed: ParsedContent,
+  doc: VideoDocument,
   workDir: string,
   workDir: string,
   assetsRoot: string,
   assetsRoot: string,
   inputDir: string,
   inputDir: string,
@@ -20,15 +28,13 @@ export async function resolveAssets(
   const assetsDir = join(workDir, "assets");
   const assetsDir = join(workDir, "assets");
   await mkdir(assetsDir, { recursive: true });
   await mkdir(assetsDir, { recursive: true });
 
 
-  // Collect all images from all scenes
-  const imageEntries: SceneImage[] = [];
-  for (const scene of parsed.scenes) {
-    if (scene.images) {
-      imageEntries.push(...scene.images);
-    }
+  // Collect + dedupe all image references across segments. Generic images come
+  // from seg.images; github-trending social-preview/star-history refs are derived
+  // from seg.extension by segmentImageRefs (so the contract stays clean).
+  const imageEntries: ResolvableImage[] = [];
+  for (const seg of doc.data) {
+    imageEntries.push(...segmentImageRefs(seg));
   }
   }
-
-  // Deduplicate
   const seen = new Set<string>();
   const seen = new Set<string>();
   const uniqueImages = imageEntries.filter((img) => {
   const uniqueImages = imageEntries.filter((img) => {
     const key = img.path || img.url || img.query || img.repoSocialPreview || "";
     const key = img.path || img.url || img.query || img.repoSocialPreview || "";
@@ -37,10 +43,6 @@ export async function resolveAssets(
     return true;
     return true;
   });
   });
 
 
-  // Resolve images: path > repoSocialPreview > url > query
-  // (repoSocialPreview takes priority over url so that an LLM that tacks an
-  // extra `url` field onto the social-preview entry doesn't accidentally make
-  // us fetch the github.com HTML page instead of the crawler's PNG.)
   const images: AssetManifest["images"] = [];
   const images: AssetManifest["images"] = [];
   for (let i = 0; i < uniqueImages.length; i++) {
   for (let i = 0; i < uniqueImages.length; i++) {
     const img = uniqueImages[i];
     const img = uniqueImages[i];
@@ -93,7 +95,7 @@ export async function resolveAssets(
     }
     }
   }
   }
 
 
-  // Backgrounds: pick template-specific background for each scene
+  // Backgrounds: template-specific default per aspect; cover prefers its own image.
   const aspectKey = assetsConfig?.aspect === "9:16" ? "9x16" : "16x9";
   const aspectKey = assetsConfig?.aspect === "9:16" ? "9x16" : "16x9";
   const templateBg = assetsConfig
   const templateBg = assetsConfig
     ? join(assetsRoot, "backgrounds", `default-${assetsConfig.template}-${aspectKey}.png`)
     ? join(assetsRoot, "backgrounds", `default-${assetsConfig.template}-${aspectKey}.png`)
@@ -101,19 +103,14 @@ export async function resolveAssets(
   const fallbackBg = join(assetsRoot, "backgrounds", "default.png");
   const fallbackBg = join(assetsRoot, "backgrounds", "default.png");
   const bgPath = templateBg && existsSync(templateBg) ? templateBg : fallbackBg;
   const bgPath = templateBg && existsSync(templateBg) ? templateBg : fallbackBg;
 
 
-  const backgrounds = parsed.scenes.map((scene, i) => {
-    // For the cover scene, prefer the user-provided cover image as the
-    // background when one was successfully resolved. Without this, the cover
-    // would always fall back to the template default background and silently
-    // ignore cover.imagePath / imageUrl / imageQuery.
-    if (scene.sceneType === "cover" && scene.images && scene.images.length > 0) {
-      const firstImg = scene.images[0];
+  const backgrounds = doc.data.map((seg, i) => {
+    if (seg.kind === "cover" && seg.images && seg.images.length > 0) {
+      const firstImg = seg.images[0];
       const resolved = images.find(
       const resolved = images.find(
         (a) =>
         (a) =>
           (firstImg.path && a.path === firstImg.path) ||
           (firstImg.path && a.path === firstImg.path) ||
           (firstImg.url && a.url === firstImg.url) ||
           (firstImg.url && a.url === firstImg.url) ||
-          (firstImg.query && a.query === firstImg.query) ||
-          (firstImg.repoSocialPreview && a.repoSocialPreview === firstImg.repoSocialPreview)
+          (firstImg.query && a.query === firstImg.query)
       );
       );
       if (resolved) {
       if (resolved) {
         return { id: `bg-${i}`, localPath: resolved.localPath };
         return { id: `bg-${i}`, localPath: resolved.localPath };
@@ -127,54 +124,30 @@ export async function resolveAssets(
     backgrounds,
     backgrounds,
     icons: [],
     icons: [],
     fonts: [
     fonts: [
-      {
-        family: "Noto Sans SC",
-        weight: 400,
-        filePath: join(assetsRoot, "fonts", "NotoSansSC-Regular.ttf"),
-      },
-      {
-        family: "Noto Sans SC",
-        weight: 700,
-        filePath: join(assetsRoot, "fonts", "NotoSansSC-Bold.ttf"),
-      },
+      { family: "Noto Sans SC", weight: 400, filePath: join(assetsRoot, "fonts", "NotoSansSC-Regular.ttf") },
+      { family: "Noto Sans SC", weight: 700, filePath: join(assetsRoot, "fonts", "NotoSansSC-Bold.ttf") },
     ],
     ],
   };
   };
 }
 }
 
 
-/** Derive a file extension from a remote URL. Falls back through the URL
- *  pathname's extension and then the last path segment (handles endpoints like
- *  https://api.star-history.com/svg?repos=... where "svg" is the resource name
- *  but there is no dot extension). Defaults to .jpg. */
 function inferUrlExtension(url: string): string {
 function inferUrlExtension(url: string): string {
   const known = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif"]);
   const known = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif"]);
   try {
   try {
     const pathname = new URL(url).pathname;
     const pathname = new URL(url).pathname;
     const ext = extname(pathname).toLowerCase();
     const ext = extname(pathname).toLowerCase();
-    if (known.has(ext)) {
-      return ext === ".jpeg" ? ".jpg" : ext;
-    }
-    // No dot extension — try the last path segment (e.g. /svg → svg).
+    if (known.has(ext)) return ext === ".jpeg" ? ".jpg" : ext;
     const seg = pathname.split("/").filter(Boolean).pop() ?? "";
     const seg = pathname.split("/").filter(Boolean).pop() ?? "";
-    if (seg && known.has(`.${seg}`)) {
-      return seg === "jpeg" ? ".jpg" : `.${seg}`;
-    }
+    if (seg && known.has(`.${seg}`)) return seg === "jpeg" ? ".jpg" : `.${seg}`;
   } catch {
   } catch {
     // ignore malformed URLs
     // ignore malformed URLs
   }
   }
   return ".jpg";
   return ".jpg";
 }
 }
 
 
-/**
- * Fetch a GitHub repo's social preview image via the crawler, decode the
- * base64 PNG and write it to `destPath`. Returns a resolved asset entry on
- * success, or `null` (with a console warning) on any failure so the caller
- * can fall through to the next strategy. Trims whitespace from `repoKey`
- * to absorb minor LLM formatting drift.
- */
 async function resolveRepoSocialPreview(
 async function resolveRepoSocialPreview(
   repoKey: string,
   repoKey: string,
   destPath: string,
   destPath: string,
-  id: string,
+  id: string
 ): Promise<AssetManifest["images"][number] | null> {
 ): Promise<AssetManifest["images"][number] | null> {
   const trimmed = repoKey.trim();
   const trimmed = repoKey.trim();
   const parts = trimmed.split("/").map((p) => p.trim()).filter(Boolean);
   const parts = trimmed.split("/").map((p) => p.trim()).filter(Boolean);
@@ -182,7 +155,6 @@ async function resolveRepoSocialPreview(
     console.warn(`[assets] repoSocialPreview "${repoKey}" is not in "owner/name" form — skipping`);
     console.warn(`[assets] repoSocialPreview "${repoKey}" is not in "owner/name" form — skipping`);
     return null;
     return null;
   }
   }
-  // Use only the first two segments (some inputs arrive as "owner/name/extra").
   const [owner, name] = parts;
   const [owner, name] = parts;
   const url = REPO_DETAIL_URL
   const url = REPO_DETAIL_URL
     .replace(":owner", encodeURIComponent(owner))
     .replace(":owner", encodeURIComponent(owner))
@@ -202,38 +174,23 @@ async function resolveRepoSocialPreview(
       return null;
       return null;
     }
     }
     const buf = Buffer.from(base64, "base64");
     const buf = Buffer.from(base64, "base64");
-    // PNG magic bytes: 89 50 4E 47 (\x89PNG). Sanity-check before writing so
-    // a malformed response doesn't produce a corrupt file that the renderer
-    // then fails to decode.
     if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
     if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
       console.warn(`[assets] crawler ${url} returned non-PNG socialPreviewImageBase64 for "${repoKey}"`);
       console.warn(`[assets] crawler ${url} returned non-PNG socialPreviewImageBase64 for "${repoKey}"`);
       return null;
       return null;
     }
     }
     await writeFile(destPath, buf);
     await writeFile(destPath, buf);
-    return {
-      id,
-      localPath: destPath,
-      repoSocialPreview: trimmed,
-      altText: trimmed,
-    };
+    return { id, localPath: destPath, repoSocialPreview: trimmed, altText: trimmed };
   } catch (err) {
   } catch (err) {
     console.warn(`[assets] error fetching repoSocialPreview "${repoKey}":`, err instanceof Error ? err.message : err);
     console.warn(`[assets] error fetching repoSocialPreview "${repoKey}":`, err instanceof Error ? err.message : err);
     return null;
     return null;
   }
   }
 }
 }
 
 
-/**
- * Fetch a remote image URL and write it to `destPath`. Only attempts when the
- * URL is well-formed (http/https) so an LLM that puts a bare "owner/name"
- * string into a `url` field doesn't make us try to fetch a relative path.
- * Returns a resolved asset entry on success, or `null` (with a console
- * warning on unexpected failures) so the caller can fall through.
- */
 async function fetchUrlImage(
 async function fetchUrlImage(
   url: string,
   url: string,
   destPath: string,
   destPath: string,
   id: string,
   id: string,
-  query?: string,
+  query?: string
 ): Promise<AssetManifest["images"][number] | null> {
 ): Promise<AssetManifest["images"][number] | null> {
   if (!/^https?:\/\//i.test(url)) {
   if (!/^https?:\/\//i.test(url)) {
     console.warn(`[assets] skipping non-http(s) url: ${url}`);
     console.warn(`[assets] skipping non-http(s) url: ${url}`);
@@ -251,14 +208,7 @@ async function fetchUrlImage(
       return null;
       return null;
     }
     }
     await writeFile(destPath, buf);
     await writeFile(destPath, buf);
-    return {
-      id,
-      localPath: destPath,
-      sourceUrl: url,
-      url,
-      query,
-      altText: query,
-    };
+    return { id, localPath: destPath, sourceUrl: url, url, query, altText: query };
   } catch (err) {
   } catch (err) {
     console.warn(`[assets] error fetching url ${url}:`, err instanceof Error ? err.message : err);
     console.warn(`[assets] error fetching url ${url}:`, err instanceof Error ? err.message : err);
     return null;
     return null;

+ 95 - 0
packages/renderer/src/compose.ts

@@ -0,0 +1,95 @@
+import type {
+  VideoDocument,
+  AssetManifest,
+  RenderProps,
+  RenderScene,
+  PlatformPreset,
+  TemplateType,
+} from "@pipeline/shared";
+import { PLATFORM_PRESETS } from "@pipeline/shared";
+import { basename } from "node:path";
+import { segmentImageRefs, resolveGithubImages } from "./github-images.js";
+
+/**
+ * Project a document into the render-time `RenderProps` for one platform:
+ * resolve asset filenames for staticFile() and carry each segment's AUDIO
+ * duration. No frame/size computation here — the TEMPLATE computes the visual
+ * timeline (it may extend beyond audio for silent padding, holds, etc.), and
+ * the Remotion Composition owns fps/width/height/durationInFrames.
+ */
+export function composeRenderProps(
+  doc: VideoDocument,
+  assets: AssetManifest,
+  platform: PlatformPreset,
+  template: TemplateType,
+  channelName = "Pipeline"
+): RenderProps {
+  const scenes: RenderScene[] = doc.data.map((seg, i) => {
+    const backgroundAsset = assets.backgrounds[i]
+      ? { id: assets.backgrounds[i].id, filename: basename(assets.backgrounds[i].localPath) }
+      : undefined;
+
+    const images = segmentImageRefs(seg)
+      .map((img) => {
+        const resolved = assets.images.find(
+          (a) =>
+            (img.path && a.path === img.path) ||
+            (img.url && a.url === img.url) ||
+            (img.query && a.query === img.query) ||
+            (img.repoSocialPreview && a.repoSocialPreview === img.repoSocialPreview)
+        );
+        return resolved
+          ? { filename: basename(resolved.localPath), url: img.url, query: img.query }
+          : undefined;
+      })
+      .filter((x): x is NonNullable<typeof x> => !!x);
+
+    // Resolve the per-template extension (github-trending images → filenames).
+    const extension = seg.extension?.type === "github-trending"
+      ? {
+          type: "github-trending" as const,
+          repo: seg.extension.repo,
+          highlights: seg.extension.highlights,
+          intro: seg.extension.intro,
+          review: seg.extension.review,
+          images: resolveGithubImages(seg.extension, assets),
+        }
+      : undefined;
+
+    return {
+      id: seg.id,
+      kind: seg.kind,
+      index: seg.index,
+      title: seg.title,
+      desc: seg.desc,
+      captionOrigin: seg.captionOrigin,
+      caption: seg.caption,
+      cardList: seg.cardList,
+      menu: seg.menu,
+      images,
+      layoutHint: seg.layoutHint,
+      extension,
+      duration: seg.duration, // audio seconds (template computes visual frames)
+      audioFilename: seg.captionAudioFileUrl ? basename(seg.captionAudioFileUrl) : "",
+      backgroundAsset,
+    };
+  });
+
+  return {
+    scenes,
+    template,
+    platform,
+    title: doc.meta?.title || "",
+    subtitle: doc.meta?.subtitle || "",
+    channelName,
+    trendSummary: doc.meta?.trendSummary,
+    globalStyle: { tone: "formal", pace: "normal" },
+  };
+}
+
+/** Composition id the Remotion root registers (Root.tsx uses `${template}-${landscape|portrait}`). */
+export function compositionIdFor(template: TemplateType, platform: string): string {
+  const preset = PLATFORM_PRESETS[platform as PlatformPreset];
+  const aspectKey = preset.aspect === "16:9" ? "landscape" : "portrait";
+  return `${template}-${aspectKey}`;
+}

+ 98 - 0
packages/renderer/src/export-file.ts

@@ -0,0 +1,98 @@
+import type { VideoDocument, ExportFile, PublishMeta } from "@pipeline/shared";
+import { isoDateString } from "@pipeline/shared";
+import { getTimezone, createLogger } from "@pipeline/shared/node";
+import { join } from "node:path";
+import { mkdir, writeFile, copyFile, stat } from "node:fs/promises";
+import { stringify as stringifyYaml } from "yaml";
+import type { PublishTargetConfig } from "./types.js";
+
+const log = createLogger("export");
+
+/** Dimensions + duration of the actually-rendered composition (from renderMedia),
+ *  plus platform/template identity. The visual timeline is template-computed, so
+ *  these come from the resolved composition — not pre-computed by the renderer. */
+export interface ExportContext {
+  platform: string;
+  template: string;
+  width: number;
+  height: number;
+  fps: number;
+  durationInFrames: number;
+}
+
+/** Build the sidecar manifest object for one rendered file. `publish` is the
+ *  LLM-produced posting metadata (kept OUT of VideoDocument, passed separately);
+ *  `publishMeta` is the platform-specific config (分区 tid/category, extra tags). */
+export function buildPublishManifest(
+  ctx: ExportContext,
+  publish: PublishMeta | undefined,
+  doc: VideoDocument,
+  fileBasename: string,
+  publishMeta?: PublishTargetConfig
+): Record<string, unknown> {
+  const configTags = publishMeta?.tags ?? [];
+  const llmTags = publish?.tags ?? [];
+  const tags = [...llmTags, ...configTags]
+    .filter((t): t is string => typeof t === "string" && t.length > 0)
+    .filter((t, i, arr) => arr.indexOf(t) === i);
+
+  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 || doc.meta?.title || "",
+    description: publish?.description || "",
+    tags,
+    platform: ctx.platform,
+    template: ctx.template,
+    file: `${fileBasename}.mp4`,
+    durationSeconds: Number((ctx.durationInFrames / ctx.fps).toFixed(2)),
+    date: isoDateString(new Date(), getTimezone()),
+  };
+  if (Object.keys(category).length > 0) manifest.category = category;
+  return manifest;
+}
+
+/** Copy the rendered MP4 to {outputDir}/{template}/{ISO-date}/{name}.mp4 and
+ *  write a best-effort sidecar YAML manifest. */
+export async function exportVideo(
+  renderedPath: string,
+  ctx: ExportContext,
+  doc: VideoDocument,
+  outputDir: string,
+  jobId: string,
+  publish: PublishMeta | undefined,
+  publishMeta?: PublishTargetConfig
+): Promise<ExportFile> {
+  const subDir = join(outputDir, ctx.template, isoDateString(new Date(), getTimezone()));
+  await mkdir(subDir, { recursive: true });
+
+  const statResult = await stat(renderedPath);
+  const fileBasename = `${ctx.template}-${ctx.platform}-${jobId.slice(0, 8)}`;
+  const finalPath = join(subDir, `${fileBasename}.mp4`);
+  await copyFile(renderedPath, finalPath);
+
+  try {
+    const manifestPath = join(subDir, `${fileBasename}.yaml`);
+    const manifest = buildPublishManifest(ctx, publish, doc, 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}`);
+  }
+
+  return {
+    platform: ctx.platform,
+    filePath: finalPath,
+    fileSizeBytes: statResult.size,
+    width: ctx.width,
+    height: ctx.height,
+    durationSeconds: ctx.durationInFrames / ctx.fps,
+    format: "mp4",
+    codec: "h264",
+  };
+}

+ 58 - 0
packages/renderer/src/github-images.ts

@@ -0,0 +1,58 @@
+import type { VideoDocument, AssetManifest, RenderGithubImages, GithubTrendingExtension } from "@pipeline/shared";
+import { basename } from "node:path";
+
+/**
+ * Internal resolvable image ref — richer than the contract's `DocumentImage`:
+ * the renderer synthesizes the github-trending social-preview ref from the
+ * segment's `extension` and resolves it via the crawler. This type is
+ * renderer-internal; the VideoDocument contract never carries `repoSocialPreview`.
+ */
+export interface ResolvableImage {
+  path?: string;
+  url?: string;
+  query?: string;
+  repoSocialPreview?: string;
+}
+
+/**
+ * The generic image refs a segment needs the renderer to resolve: its generic
+ * `images` (path/url/query) PLUS, for github-trending segments, the social-
+ * preview (crawler) and star-history (URL) refs declared in `extension.images`.
+ *
+ * `extension.images` is an OBJECT (named fields); this flattens those named refs
+ * into the resolvable list for the generic assets stage, then `resolveGithubImages`
+ * re-keys the resolved results back into the named object for the template.
+ */
+export function segmentImageRefs(seg: VideoDocument["data"][number]): ResolvableImage[] {
+  const refs: ResolvableImage[] = (seg.images ?? []).map((i) => ({ ...i }));
+  if (seg.extension?.type === "github-trending") {
+    const imgs = seg.extension.images;
+    if (imgs?.socialPreview) refs.push({ repoSocialPreview: imgs.socialPreview });
+    if (imgs?.starHistory) refs.push({ url: imgs.starHistory });
+  }
+  return refs;
+}
+
+/**
+ * Re-key the resolved github-trending images back into the named object the
+ * template consumes (socialPreview / starHistory → resolved RenderImage with a
+ * filename). Returns undefined if the extension declares no images or none
+ * resolved. `ext` is the contract (refs); `assets` is the resolved manifest.
+ */
+export function resolveGithubImages(
+  ext: GithubTrendingExtension,
+  assets: AssetManifest
+): RenderGithubImages | undefined {
+  const refs = ext.images;
+  if (!refs) return undefined;
+  const out: RenderGithubImages = {};
+  if (refs.socialPreview) {
+    const a = assets.images.find((x) => x.repoSocialPreview === refs.socialPreview);
+    if (a) out.socialPreview = { filename: basename(a.localPath) };
+  }
+  if (refs.starHistory) {
+    const a = assets.images.find((x) => x.url === refs.starHistory);
+    if (a) out.starHistory = { filename: basename(a.localPath) };
+  }
+  return Object.keys(out).length > 0 ? out : undefined;
+}

+ 76 - 0
packages/renderer/src/index.ts

@@ -0,0 +1,76 @@
+import { join } from "node:path";
+import { mkdir } from "node:fs/promises";
+import type { VideoDocument, ExportFile, PlatformPreset } from "@pipeline/shared";
+import { PLATFORM_PRESETS, isoDateString } from "@pipeline/shared";
+import { getTimezone, createLogger } from "@pipeline/shared/node";
+import { resolveAssets } from "./assets.js";
+import { composeRenderProps } from "./compose.js";
+import { preparePublicDir, renderVideo } from "./render.js";
+import { exportVideo } from "./export-file.js";
+import { uploadToOss } from "./oss.js";
+import type { RenderOptions } from "./types.js";
+
+const log = createLogger("renderer");
+
+export type { RenderOptions, PublishTargetConfig, OssConfig } from "./types.js";
+export type { RenderProps, RenderScene } from "@pipeline/shared";
+export { composeRenderProps, compositionIdFor } from "./compose.js";
+
+/**
+ * Remotion 调度器(Module 3)入口。
+ *
+ * 接收平台无关的 VideoDocument,逐平台:解析/下载图片、折算帧时间轴、打包
+ * Remotion bundle、渲染 MP4、导出到统一输出目录并写 .yaml 清单、(可选)上传
+ * OSS。音频按 audioFilenameFor(id) 命名复制进 publicDir(源 = 段的 captionAudioFileUrl)。
+ *
+ * 返回每个平台的 ExportFile(含 ossUrl)。OSS 上传失败只告警,不中断(渲染本身已成功)。
+ */
+export async function renderDocument(
+  doc: VideoDocument,
+  opts: RenderOptions
+): Promise<ExportFile[]> {
+  await mkdir(opts.workDir, { recursive: true });
+
+  const files: ExportFile[] = [];
+
+  for (const platform of opts.platforms) {
+    const assets = await resolveAssets(doc, opts.workDir, opts.assetsRoot, opts.inputDir, {
+      template: doc.template,
+      aspect: PLATFORM_PRESETS[platform].aspect,
+    });
+
+    const renderProps = composeRenderProps(doc, assets, platform, doc.template, opts.channelName);
+
+    const publicDir = await preparePublicDir(doc, assets, opts.assetsRoot, join(opts.workDir, `public-${platform}`));
+    const renderOutput = join(opts.workDir, `render-${platform}.mp4`);
+    log.info(`render ${platform} -> ${renderOutput}`);
+    const rendered = await renderVideo(renderProps, publicDir, renderOutput, opts.templatesEntry);
+
+    const publishMeta = opts.publishMeta?.[platform]?.[doc.template];
+    const ctx = {
+      platform,
+      template: doc.template,
+      width: rendered.width,
+      height: rendered.height,
+      fps: rendered.fps,
+      durationInFrames: rendered.durationInFrames,
+    };
+    const file = await exportVideo(rendered.outputPath, ctx, doc, opts.outputDir, opts.jobId, opts.publish, publishMeta);
+
+    if (opts.oss) {
+      try {
+        const dateDir = `${renderProps.template}/${isoDateString(new Date(), getTimezone())}`;
+        const { url } = await uploadToOss(file.filePath, opts.oss, { dateDir });
+        file.ossUrl = url;
+        log.info(`oss ${platform} -> ${url}`);
+      } catch (err) {
+        // Upload failure must never mask a successful render.
+        log.warn(`oss upload failed for ${platform}: ${err instanceof Error ? err.message : err}`);
+      }
+    }
+
+    files.push(file);
+  }
+
+  return files;
+}

+ 4 - 39
packages/core/src/publish/oss.ts → packages/renderer/src/oss.ts

@@ -1,31 +1,10 @@
 import OSS from "ali-oss";
 import OSS from "ali-oss";
 import { basename } from "node:path";
 import { basename } from "node:path";
 import { createLogger } from "@pipeline/shared/node";
 import { createLogger } from "@pipeline/shared/node";
+import type { OssConfig } from "./types.js";
 
 
 const log = createLogger("oss");
 const log = createLogger("oss");
 
 
-/**
- * Alibaba Cloud (Aliyun) OSS configuration.
- *
- * Non-sensitive values (region/bucket/path/publicDomain) may live in
- * config/default.yaml; secrets (access keys) should be supplied via env vars
- * (OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET).
- */
-export interface OssConfig {
-  region: string;
-  bucket: string;
-  accessKeyId: string;
-  accessKeySecret: string;
-  /** Custom endpoint, e.g. https://oss-cn-hangzhou.aliyuncs.com (internal). */
-  endpoint?: string;
-  /** Object key prefix, e.g. "videos/". */
-  path?: string;
-  /** Base URL used to build public resource links (e.g. a CDN domain). */
-  publicDomain?: string;
-  /** Use HTTPS. Defaults to true. */
-  secure?: boolean;
-}
-
 export interface OssUploadResult {
 export interface OssUploadResult {
   /** Object key within the bucket. */
   /** Object key within the bucket. */
   key: string;
   key: string;
@@ -41,7 +20,6 @@ function joinKey(prefix: string | undefined, ...parts: string[]): string {
     .join("/");
     .join("/");
 }
 }
 
 
-/** Host portion of the bucket's default domain: endpoint (minus protocol) or <region>.aliyuncs.com. */
 function ossHost(config: OssConfig): string {
 function ossHost(config: OssConfig): string {
   if (config.endpoint) {
   if (config.endpoint) {
     return config.endpoint.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
     return config.endpoint.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
@@ -49,14 +27,6 @@ function ossHost(config: OssConfig): string {
   return `${config.region}.aliyuncs.com`;
   return `${config.region}.aliyuncs.com`;
 }
 }
 
 
-/**
- * Build the public resource URL for an object. Preference:
- *   1. OSS_PUBLIC_DOMAIN/key (e.g. a CDN),
- *   2. a full http(s) URL reported by the SDK,
- *   3. constructed from bucket + region/endpoint.
- * Never falls back to the bare key (a path) — ali-oss's multipartUpload often
- * omits `.url`, which previously produced a path instead of a link.
- */
 function buildPublicUrl(config: OssConfig, key: string, result?: { url?: string }): string {
 function buildPublicUrl(config: OssConfig, key: string, result?: { url?: string }): string {
   if (config.publicDomain) {
   if (config.publicDomain) {
     return `${config.publicDomain.replace(/\/+$/, "")}/${key}`;
     return `${config.publicDomain.replace(/\/+$/, "")}/${key}`;
@@ -68,18 +38,13 @@ function buildPublicUrl(config: OssConfig, key: string, result?: { url?: string
   return `${proto}://${config.bucket}.${ossHost(config)}/${key}`;
   return `${proto}://${config.bucket}.${ossHost(config)}/${key}`;
 }
 }
 
 
-/**
- * Upload a single local file to OSS and return its public URL.
- * Uses multipart upload so large MP4s are handled reliably.
- */
+/** Upload a single local file to OSS and return its public URL. Verbatim port
+ *  of core/publish/oss.ts — multipart upload for large MP4s. */
 export async function uploadToOss(
 export async function uploadToOss(
   localPath: string,
   localPath: string,
   config: OssConfig,
   config: OssConfig,
   opts?: { dateDir?: string }
   opts?: { dateDir?: string }
 ): Promise<OssUploadResult> {
 ): Promise<OssUploadResult> {
-  // The ali-oss SDK signs for oss-<region>.aliyuncs.com. A `s3.`-prefixed
-  // (S3-compatible) endpoint signs incorrectly and OSS returns HTTP 403
-  // AccessDenied. Warn loudly — the Aliyun console often surfaces the S3 URL.
   if (config.endpoint) {
   if (config.endpoint) {
     const host = config.endpoint.replace(/^https?:\/\//i, "");
     const host = config.endpoint.replace(/^https?:\/\//i, "");
     if (host.startsWith("s3.") || host.includes(".s3.")) {
     if (host.startsWith("s3.") || host.includes(".s3.")) {
@@ -105,7 +70,7 @@ export async function uploadToOss(
   const key = joinKey(config.path, opts?.dateDir ?? "", filename);
   const key = joinKey(config.path, opts?.dateDir ?? "", filename);
 
 
   const result = await client.multipartUpload(key, localPath, {
   const result = await client.multipartUpload(key, localPath, {
-    partSize: 5 * 1024 * 1024, // 5MB parts
+    partSize: 5 * 1024 * 1024,
     timeout: 600000,
     timeout: 600000,
   });
   });
 
 

+ 116 - 0
packages/renderer/src/render.ts

@@ -0,0 +1,116 @@
+import { join, basename } from "node:path";
+import { copyFile, mkdir, stat } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import type { VideoDocument, AssetManifest, RenderProps } from "@pipeline/shared";
+import { renderMedia, getCompositions } from "@remotion/renderer";
+import { bundle } from "@remotion/bundler";
+import { compositionIdFor } from "./compose.js";
+
+export interface RenderResult {
+  outputPath: string;
+  fileSizeBytes: number;
+  width: number;
+  height: number;
+  fps: number;
+  durationInFrames: number;
+}
+
+/**
+ * Stage all assets Remotion's webpack dev server must serve into a public/
+ * directory: per-segment audio (downloaded by the audio module), background
+ * images, scene images, and the bundled fonts. Ports render.ts preparePublicDir.
+ */
+export async function preparePublicDir(
+  doc: VideoDocument,
+  assets: AssetManifest,
+  assetsRoot: string,
+  publicDir: string
+): Promise<string> {
+  await mkdir(publicDir, { recursive: true });
+
+  // Per-segment audio (source = captionAudioFileUrl; named audioFilenameFor(id)).
+  for (const seg of doc.data) {
+    if (seg.captionAudioFileUrl) {
+      try {
+        await copyFile(seg.captionAudioFileUrl, join(publicDir, basename(seg.captionAudioFileUrl)));
+      } catch {
+        // best-effort — a missing audio file shouldn't abort the bundle
+      }
+    }
+  }
+
+  // Background images + scene images.
+  for (const bg of assets.backgrounds) {
+    try {
+      await copyFile(bg.localPath, join(publicDir, basename(bg.localPath)));
+    } catch {}
+  }
+  for (const img of assets.images) {
+    try {
+      await copyFile(img.localPath, join(publicDir, basename(img.localPath)));
+    } catch {}
+  }
+
+  // Bundled fonts so templates can load them via staticFile("fonts/...").
+  const fontsDir = join(assetsRoot, "fonts");
+  if (existsSync(fontsDir)) {
+    const fontsDest = join(publicDir, "fonts");
+    await mkdir(fontsDest, { recursive: true });
+    for (const f of ["NotoSansSC-Regular.ttf", "NotoSansSC-Bold.ttf"]) {
+      const src = join(fontsDir, f);
+      if (existsSync(src)) {
+        await copyFile(src, join(fontsDest, f));
+      } else {
+        console.warn(`[render] font source missing: ${src}`);
+      }
+    }
+  } else {
+    console.warn(`[render] assets fonts dir missing: ${fontsDir}`);
+  }
+
+  return publicDir;
+}
+
+/** Bundle the Remotion entry, select the composition for this template×platform,
+ *  and render the MP4. Ports render.ts renderVideo. */
+export async function renderVideo(
+  renderProps: RenderProps,
+  publicDir: string,
+  outputPath: string,
+  templatesEntry: string
+): Promise<RenderResult> {
+  const bundleLocation = await bundle({ entryPoint: templatesEntry, publicDir });
+
+  const compositionId = compositionIdFor(renderProps.template, renderProps.platform);
+  // Remotion types inputProps as Record<string, unknown>; cast the typed props.
+  const inputProps = renderProps as unknown as Record<string, unknown>;
+  const compositions = await getCompositions(bundleLocation, { inputProps });
+  const composition = compositions.find((c) => c.id === compositionId);
+  if (!composition) {
+    const available = compositions.map((c) => c.id).join(", ");
+    throw new Error(`Composition "${compositionId}" not found. Available: ${available}`);
+  }
+
+  await renderMedia({
+    composition,
+    serveUrl: bundleLocation,
+    codec: "h264",
+    outputLocation: outputPath,
+    inputProps,
+    onProgress: ({ progress }) => {
+      if (process.stdout.isTTY) {
+        process.stdout.write(`\rRendering: ${(progress * 100).toFixed(1)}%`);
+      }
+    },
+  });
+
+  const statResult = await stat(outputPath);
+  return {
+    outputPath,
+    fileSizeBytes: statResult.size,
+    width: composition.width,
+    height: composition.height,
+    fps: composition.fps,
+    durationInFrames: composition.durationInFrames,
+  };
+}

+ 47 - 0
packages/renderer/src/types.ts

@@ -0,0 +1,47 @@
+import type { PlatformPreset, PublishMeta } from "@pipeline/shared";
+
+/** Per-platform × per-template publish sidecar config (from config.publishMeta).
+ *  Everything except `tags` passes through under the manifest's `category` block. */
+export interface PublishTargetConfig {
+  tags?: string[];
+  tid?: number;
+  category?: string;
+  [key: string]: unknown;
+}
+
+/** Alibaba Cloud OSS configuration (secrets via env). Ported from core/publish/oss.ts. */
+export interface OssConfig {
+  region: string;
+  bucket: string;
+  accessKeyId: string;
+  accessKeySecret: string;
+  endpoint?: string;
+  path?: string;
+  publicDomain?: string;
+  secure?: boolean;
+}
+
+export interface RenderOptions {
+  platforms: PlatformPreset[];
+  /** Per-job working directory (shared with the audio module). The renderer
+   *  writes assets/public/render temps under `<workDir>/`. */
+  workDir: string;
+  /** Job id — used in the exported filename (`{template}-{platform}-{jobId8}`). */
+  jobId: string;
+  /** Unified output dir (resolveOutputDir). MP4s land under {outputDir}/{template}/{date}/. */
+  outputDir: string;
+  /** Repo assets root (backgrounds/, fonts/). */
+  assetsRoot: string;
+  /** Directory local image `path`s resolve against. */
+  inputDir: string;
+  /** Remotion bundle entry (packages/templates/src/entry.ts). */
+  templatesEntry: string;
+  channelName?: string;
+  /** LLM-produced posting metadata (title/description/tags) → sidecar manifest.
+   *  NOT part of VideoDocument; passed in separately. */
+  publish?: PublishMeta;
+  /** publishMeta[platform][template] — platform-specific sidecar keys. */
+  publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
+  /** When set, each rendered MP4 is uploaded to OSS and ossUrl is filled in. */
+  oss?: OssConfig;
+}

+ 2 - 2
packages/core/src/types/ali-oss.d.ts → packages/renderer/src/types/ali-oss.d.ts

@@ -1,5 +1,5 @@
-// Minimal ambient types for `ali-oss`. We vendor these instead of depending on
-// `@types/ali-oss` to keep type-resolution robust across installs.
+// Minimal ambient types for `ali-oss`. Vendored (mirrors packages/core) instead
+// of depending on `@types/ali-oss` to keep type-resolution robust across installs.
 declare module "ali-oss" {
 declare module "ali-oss" {
   export interface OSSOptions {
   export interface OSSOptions {
     region: string;
     region: string;

+ 13 - 0
packages/renderer/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "types": ["node"]
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../shared" },
+    { "path": "../templates" }
+  ]
+}

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

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

+ 1 - 6
packages/shared/src/llm/prompts/parse-text.ts

@@ -21,7 +21,7 @@ The output JSON must follow this exact schema:
       "keyframes": [
       "keyframes": [
         { "type": "text|highlight|image|icon|transition", "content": "string" }
         { "type": "text|highlight|image|icon|transition", "content": "string" }
       ],
       ],
-      "images": "array — optional, image references. Entries may use { path } for local files, { url } for remote URLs, or { repoSocialPreview: \"owner/name\" } for GitHub repo social preview cards (only used by github-trending template).",
+      "images": "array — optional, image references. Entries may use { path } for local files or { url } for remote URLs.",
       "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license, todayStars }, highlights, intro, review }. See template-specific rules.",
       "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license, todayStars }, highlights, intro, review }. See template-specific rules.",
       "durationHint": 10,
       "durationHint": 10,
       "backgroundQuery": "string — describe the ideal background image",
       "backgroundQuery": "string — describe the ideal background image",
@@ -109,11 +109,6 @@ 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 "Today: +N" line, extract the integer N and set github.repo.todayStars to it.
 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 "Today: +N" line, extract the integer N and set github.repo.todayStars to it.
 
 
-images: array of EXACTLY TWO entries, copied from the corresponding "<!-- repo-images: ... -->" block:
-  1. { "repoSocialPreview": "owner/name" }  — DO NOT expand this into a URL. The assets stage resolves it.
-  2. { "url": "https://api.star-history.com/svg?repos=owner/name&type=Date" }  — copy the starHistory URL verbatim.
-Order matters: social preview first, star history second.
-
 github.highlights (项目亮点): one short phrase, ≤ 30 Chinese characters, naming the single most distinctive technical capability or signal. Example: "声明式 UI + 虚拟 DOM,生态最大". No filler ("这是一个强大的..."), no marketing adjectives, no language mention unless language IS the value proposition.
 github.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.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.

+ 175 - 0
packages/shared/src/types/document.ts

@@ -0,0 +1,175 @@
+import { z } from "zod";
+import {
+  GithubSceneDataSchema,
+  GlobalStyleHintsSchema,
+} from "./scene.js";
+
+/**
+ * VideoDocument — the single canonical JSON contract that flows between the
+ * three decoupled modules (text → audio → renderer). Each module validates its
+ * input against this schema and throws if it does not conform.
+ *
+ * Replaces the legacy internal chain (VideoInput → ParsedContent →
+ * ComposedProject). Field naming follows the codebase camelCase convention;
+ * the snake_case shape in docs/项目解耦 was illustrative ("可根据实际情况调整").
+ *
+ * Platform-agnostic: durations are in seconds; frame timing is computed at
+ * render time (the renderer knows fps per platform). Per-platform multiplexing
+ * happens inside the renderer module, so one document can render to many
+ * aspect ratios.
+ */
+
+export const VIDEO_DOCUMENT_VERSION = "2.0" as const;
+export const VIDEO_DOCUMENT_TYPE = "ppt" as const;
+
+// --- Caption (audio module produces, from TTS alignment) ---
+
+export const CaptionSchema = z.object({
+  text: z.string(),
+  /** Seconds this caption occupies on screen. */
+  duration: z.number(),
+});
+export type Caption = z.infer<typeof CaptionSchema>;
+
+// --- On-screen card (generalizes legacy keyframes) ---
+
+export const CardSchema = z.object({
+  title: z.string().optional(),
+  desc: z.string().optional(),
+  /** Card category, e.g. "功能简介" / "GitHub 地址" / "使用场景" (github-trending),
+   *  or a legacy keyframe type ("text"/"highlight"/"image"/"icon"/"transition"). */
+  kind: z.string().optional(),
+});
+export type Card = z.infer<typeof CardSchema>;
+
+// --- Chapter / menu entry (cover TOC, etc.) ---
+
+export const MenuEntrySchema = z.object({
+  icon: z.string().optional(),
+  title: z.string(),
+  desc: z.string().optional(),
+});
+export type MenuEntry = z.infer<typeof MenuEntrySchema>;
+
+/**
+ * Generic image reference in the output document — only template-agnostic
+ * source forms. Template-specific sources (e.g. a GitHub repo's social preview
+ * fetched via a crawler) live in the per-template `extension`, not here.
+ */
+export const DocumentImageSchema = z.object({
+  path: z.string().optional(),
+  url: z.string().optional(),
+  query: z.string().optional(),
+});
+export type DocumentImage = z.infer<typeof DocumentImageSchema>;
+
+/**
+ * Per-template segment extension — the home for template-specific data, kept
+ * OUT of the generic VideoSegment so the contract stays clean. Discriminated by
+ * `type`; add a variant per template that needs custom fields. The renderer and
+ * the matching template read a segment's extension to do template-specific work
+ * (e.g. github-trending resolves the repo's social-preview + star-history images).
+ */
+/**
+ * The named images a github-trending scene renders, declared explicitly by the
+ * template (in its extension) so they are visible in the JSON and template-
+ * controlled — NOT derived by the renderer. An OBJECT (not an array): these are
+ * a fixed, named set, so named fields beat positional indices. The renderer
+ * resolves each (social preview via the repo crawler; star history via URL).
+ */
+export const GithubTrendingImagesSchema = z.object({
+  /** Repo social-preview card, as "owner/name" — resolved via the repo crawler. */
+  socialPreview: z.string().optional(),
+  /** Star-history SVG URL. */
+  starHistory: z.string().optional(),
+});
+export type GithubTrendingImages = z.infer<typeof GithubTrendingImagesSchema>;
+
+export const GithubTrendingExtensionSchema = GithubSceneDataSchema.extend({
+  type: z.literal("github-trending"),
+  /** 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 const SegmentExtensionSchema = z.discriminatedUnion("type", [
+  GithubTrendingExtensionSchema,
+]);
+export type SegmentExtension = z.infer<typeof SegmentExtensionSchema>;
+
+export const SegmentKindSchema = z.enum(["cover", "content", "outro"]);
+export type SegmentKind = z.infer<typeof SegmentKindSchema>;
+
+// --- A single segment / scene ---
+
+export const VideoSegmentSchema = z.object({
+  /** Stable id; also the key used to name its audio file (see audioFilenameFor). */
+  id: z.string(),
+  kind: SegmentKindSchema.default("content"),
+  index: z.number().optional(),
+  /** On-screen headline (legacy displayText — e.g. a repo fullName). */
+  title: z.string().optional(),
+  desc: z.string().optional(),
+  /** Full narration text for this segment. Text module sets it; the audio module
+   *  synthesizes audio from it and segments it into `caption`. */
+  captionOrigin: z.string().optional(),
+  /** Split captions with per-segment duration. Audio module fills this. */
+  caption: z.array(CaptionSchema).optional(),
+  /** Audio file URL or local path. Audio module fills it; the renderer downloads
+   *  the file using audioFilenameFor(id). */
+  captionAudioFileUrl: z.string().optional(),
+  /** On-screen info cards (legacy keyframes). */
+  cardList: z.array(CardSchema).optional(),
+  /** Chapter / menu entries. */
+  menu: z.array(MenuEntrySchema).optional(),
+  /** Generic image references (path > url > query). Template-specific image
+   *  sources are derived from `extension` by the renderer, not listed here. */
+  images: z.array(DocumentImageSchema).optional(),
+  layoutHint: z.string().optional(),
+  /** Per-template extension (github-trending repo data, etc.). Optional. */
+  extension: SegmentExtensionSchema.optional(),
+  /** TTS speed override (0.5–2.0). */
+  speed: z.number().min(0.5).max(2.0).optional(),
+  /** AUDIO duration in seconds (length of the synthesized audio for this
+   *  segment's caption_origin). 0 / undefined when the segment has no audio.
+   *  This is NOT the visual/scene duration — the template computes that (it may
+   *  add silent padding, holds, transitions, etc., so visuals need not match
+   *  audio exactly). */
+  duration: z.number().optional(),
+});
+export type VideoSegment = z.infer<typeof VideoSegmentSchema>;
+
+// --- Document-level config & meta ---
+
+export const VideoDocumentConfigSchema = z.object({
+  themeColor: z.string().optional(),
+  /** Reserved: background music URL. NOT implemented this round (audio stays
+   *  per-segment narration only, as today). */
+  bgmFileUrl: z.string().optional(),
+  globalStyle: GlobalStyleHintsSchema.optional(),
+  channelName: z.string().optional(),
+});
+export type VideoDocumentConfig = z.infer<typeof VideoDocumentConfigSchema>;
+
+export const VideoDocumentMetaSchema = z.object({
+  /** Video title. For github-trending this is the cover masthead ("GitHub 每日热榜")
+   *  and also feeds the sidecar manifest + Feishu title suggestion. */
+  title: z.string().optional(),
+  /** Cover subtitle (e.g. today's date for github-trending). */
+  subtitle: z.string().optional(),
+  summary: z.string().optional(),
+  /** github-trending one-line trend — cover subtitle line + Feishu title suggestion. */
+  trendSummary: z.string().optional(),
+});
+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"]),
+  config: VideoDocumentConfigSchema.optional(),
+  meta: VideoDocumentMetaSchema.optional(),
+  data: z.array(VideoSegmentSchema),
+});
+export type VideoDocument = z.infer<typeof VideoDocumentSchema>;

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

@@ -55,3 +55,36 @@ export type {
   ExportFile,
   ExportFile,
   ExportManifest,
   ExportManifest,
 } from "./pipeline.js";
 } from "./pipeline.js";
+
+export {
+  VIDEO_DOCUMENT_VERSION,
+  VIDEO_DOCUMENT_TYPE,
+  CaptionSchema,
+  CardSchema,
+  MenuEntrySchema,
+  DocumentImageSchema,
+  GithubTrendingImagesSchema,
+  GithubTrendingExtensionSchema,
+  SegmentExtensionSchema,
+  SegmentKindSchema,
+  VideoSegmentSchema,
+  VideoDocumentConfigSchema,
+  VideoDocumentMetaSchema,
+  VideoDocumentSchema,
+} from "./document.js";
+export type {
+  Caption,
+  Card,
+  MenuEntry,
+  DocumentImage,
+  GithubTrendingImages,
+  GithubTrendingExtension,
+  SegmentExtension,
+  SegmentKind,
+  VideoSegment,
+  VideoDocumentConfig,
+  VideoDocumentMeta,
+  VideoDocument,
+} from "./document.js";
+
+export type { RenderImage, RenderGithubImages, RenderSegmentExtension, RenderScene, RenderProps } from "./render.js";

+ 82 - 0
packages/shared/src/types/render.ts

@@ -0,0 +1,82 @@
+import type {
+  SegmentKind,
+  Caption,
+  Card,
+  MenuEntry,
+} from "./document.js";
+import type { RepoMeta } from "./scene.js";
+import type { TemplateType } from "../constants.js";
+
+/** Resolved image reference as the renderer hands it to Remotion (filename for staticFile). */
+export interface RenderImage {
+  filename: string;
+  url?: string;
+  query?: string;
+}
+
+/** github-trending's named images, RESOLVED to filenames (render-time form of
+ *  GithubTrendingImages). Object, not array — fixed named fields, no positional
+ *  coupling. */
+export interface RenderGithubImages {
+  socialPreview?: RenderImage;
+  starHistory?: RenderImage;
+}
+
+/** Render-time (resolved) per-template extension. Mirrors SegmentExtension but
+ *  with images resolved to filenames. Built by the renderer; templates read this. */
+export type RenderSegmentExtension = {
+  type: "github-trending";
+  repo: RepoMeta;
+  highlights: string;
+  intro: string;
+  review: string;
+  images?: RenderGithubImages;
+};
+
+/**
+ * Render-time projection of a VideoSegment — the shape the Remotion template
+ * consumes. Built by the renderer module; templates read ONLY this shape.
+ *
+ * Distinct from VideoSegment (the inter-module JSON contract): here images are
+ * resolved to filenames and audio duration is filled in for a specific platform.
+ */
+export interface RenderScene {
+  id: string;
+  kind: SegmentKind;
+  index?: number;
+  /** On-screen headline (e.g. repo fullName). */
+  title?: string;
+  desc?: string;
+  captionOrigin?: string;
+  /** Pre-split subtitles with per-segment duration (from the audio module). */
+  caption?: Caption[];
+  cardList?: Card[];
+  menu?: MenuEntry[];
+  images?: RenderImage[];
+  layoutHint?: string;
+  /** Per-template extension (github-trending repo data + resolved images). Optional. */
+  extension?: RenderSegmentExtension;
+
+  // --- render-time ---
+  /** AUDIO duration in seconds (0 / undefined when silent). The visual/scene
+   *  duration is computed by the TEMPLATE (it may add silent padding, holds, etc.
+   *  so visuals need not match audio). */
+  duration?: number;
+  /** Basename of the audio file in publicDir (staticFile); "" when silent. */
+  audioFilename: string;
+  backgroundAsset?: { id: string; filename: string };
+}
+
+export interface RenderProps {
+  scenes: RenderScene[];
+  template: TemplateType;
+  platform: string;
+  /** Cover masthead title (doc.meta.title). */
+  title: string;
+  /** Cover subtitle, e.g. the date (doc.meta.subtitle). */
+  subtitle: string;
+  channelName: string;
+  /** github-trending one-liner (doc.meta.trendSummary). */
+  trendSummary?: string;
+  globalStyle: { tone: string; pace: string };
+}

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

@@ -26,7 +26,6 @@ export const SceneImageSchema = z.object({
   path: z.string().optional(),
   path: z.string().optional(),
   url: z.string().optional(),
   url: z.string().optional(),
   query: z.string().optional(),
   query: z.string().optional(),
-  repoSocialPreview: z.string().optional(),
 });
 });
 
 
 export type SceneImage = z.infer<typeof SceneImageSchema>;
 export type SceneImage = z.infer<typeof SceneImageSchema>;

+ 10 - 0
packages/shared/src/utils/audio-filename.ts

@@ -0,0 +1,10 @@
+/**
+ * Stable filename for a segment's audio — the shared naming convention between
+ * the audio module (names the file it synthesizes) and the renderer module
+ * (names the file it downloads). Derived from the segment id so the two modules
+ * agree without an out-of-band contract (per docs/项目解耦: "文件命名要根据JSON
+ * 字段 path 来命名(共用工具函数)").
+ */
+export function audioFilenameFor(segmentId: string, format: "mp3" | "wav" | "pcm" = "mp3"): string {
+  return `${segmentId}.${format}`;
+}

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

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

+ 98 - 110
packages/templates/src/Root.tsx

@@ -1,7 +1,6 @@
 import React, { useEffect, useState } from "react";
 import React, { useEffect, useState } from "react";
 import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
 import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
-import type { TemplateType, AspectRatio } from "@pipeline/shared";
-import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
+import type { TemplateType, AspectRatio, RenderScene, RenderProps } from "@pipeline/shared";
 import { formatCount } from "@pipeline/shared";
 import { formatCount } from "@pipeline/shared";
 import NewsScene from "./news/index";
 import NewsScene from "./news/index";
 import KnowledgeScene from "./knowledge/index";
 import KnowledgeScene from "./knowledge/index";
@@ -10,48 +9,32 @@ import MarketingScene from "./marketing/index";
 import GithubTrendingScene, { ColorDot } from "./github-trending/index";
 import GithubTrendingScene, { ColorDot } from "./github-trending/index";
 import { THEMES } from "./base/theme/colors";
 import { THEMES } from "./base/theme/colors";
 
 
-export interface RemotionScene {
-  id: string;
-  sceneType: "cover" | "content" | "summary" | "outro";
-  startFrame: number;
-  endFrame: number;
-  narration: string;
-  displayText?: string;
-  title?: string;
-  wordTimestamps: WordTimestamp[];
-  keyframes: Array<{
-    type: string;
-    content: string;
-    startFrame?: number;
-    endFrame?: number;
-    style?: Record<string, string>;
-  }>;
-  images?: Array<{ url?: string; query?: string; filename: string }>;
-  audioFilename: string;
-  backgroundAsset?: { id: string; filename: string };
-  layoutHint: string;
-  github?: GithubSceneData;
-  // github-trending cover scene only: theme tags + trend line.
-  coverTags?: string[];
-  trendSummary?: string;
-}
+// Re-export the render-time contract so legacy imports (`import { RemotionScene
+// } from "../Root"`) keep resolving during the migration. New code should import
+// RenderScene / RenderProps directly from @pipeline/shared.
+export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
+import type { RenderSegmentExtension } from "@pipeline/shared";
 
 
-export interface RemotionProps {
-  scenes: RemotionScene[];
-  template: TemplateType;
-  platform: string;
-  title: string;
-  subtitle?: string;
-  outro?: {
-    text: string;
-    narration?: string;
-    cta?: string;
-  };
-  channelName: string;
-  globalStyle: {
-    tone: string;
-    pace: string;
-  };
+/** 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. */
+const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
+  s.extension?.type === "github-trending" ? 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
+ *  add silent padding, holds, transitions, etc. */
+const SILENT_SCENE_SECONDS = 1;
+
+/**
+ * Visual duration in FRAMES for a scene — computed by the TEMPLATE, not the
+ * renderer. `scene.duration` is the AUDIO length only; this is where a template
+ * decides the on-screen length (default = audio, silent scenes get a small
+ * floor). Override/extend per template to add silent padding or special timing.
+ */
+function sceneDurationFrames(scene: RenderScene, fps: number): number {
+  const audio = scene.duration ?? 0;
+  const seconds = audio > 0 ? audio : SILENT_SCENE_SECONDS;
+  return Math.max(1, Math.round(seconds * fps));
 }
 }
 
 
 const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
 const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
@@ -74,55 +57,66 @@ const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
 
 
 const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
 const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
 
 
-const TemplateComposition: React.FC<RemotionProps> = (props) => {
+const TemplateComposition: React.FC<RenderProps> = (props) => {
+  const { fps } = useVideoConfig();
   const SceneComponent = SCENE_MAP[props.template];
   const SceneComponent = SCENE_MAP[props.template];
-  const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
   // github-trending: the cover lists every repo (name / language / today's
   // github-trending: the cover lists every repo (name / language / today's
-  // star gain / one-line description). Content scenes each carry scene.github.
+  // star gain / one-line description). Content scenes carry repo data in extension.
   const coverRepos = props.scenes.filter(
   const coverRepos = props.scenes.filter(
-    (s) => s.sceneType === "content" && s.github
+    (s) => s.kind === "content" && ghExt(s)
   );
   );
 
 
+  // Compute the visual timeline (TEMPLATE-controlled). Each scene's visual
+  // frames come from sceneDurationFrames (default = audio duration); the
+  // cumulative cursor places Sequences. Visuals may exceed audio (silent pads).
+  let cursor = 0;
+  const layout = props.scenes.map((scene) => {
+    const durationFrames = sceneDurationFrames(scene, fps);
+    const startFrame = cursor;
+    cursor += durationFrames;
+    return { scene, startFrame, endFrame: startFrame + durationFrames, durationFrames };
+  });
+  const totalFrames = cursor || 90;
+
   return (
   return (
     <AbsoluteFill style={{ backgroundColor: "#000" }}>
     <AbsoluteFill style={{ backgroundColor: "#000" }}>
-      {props.scenes.map((scene) => {
-        const duration = scene.endFrame - scene.startFrame;
+      {layout.map(({ scene, startFrame, durationFrames }) => {
         const sceneAudio = scene.audioFilename ? (
         const sceneAudio = scene.audioFilename ? (
           <Audio src={staticFile(scene.audioFilename)} />
           <Audio src={staticFile(scene.audioFilename)} />
         ) : null;
         ) : null;
 
 
-        if (scene.sceneType === "cover") {
+        if (scene.kind === "cover") {
           return (
           return (
             <Sequence
             <Sequence
               key={scene.id}
               key={scene.id}
-              from={scene.startFrame}
-              durationInFrames={duration}
+              from={startFrame}
+              durationInFrames={durationFrames}
             >
             >
               {sceneAudio}
               {sceneAudio}
               <CoverScene
               <CoverScene
                 template={props.template}
                 template={props.template}
                 title={props.title}
                 title={props.title}
                 subtitle={props.subtitle}
                 subtitle={props.subtitle}
-                keyframes={scene.keyframes}
+                cardList={scene.cardList}
                 backgroundAsset={scene.backgroundAsset}
                 backgroundAsset={scene.backgroundAsset}
                 coverRepos={coverRepos}
                 coverRepos={coverRepos}
-                trendSummary={scene.trendSummary}
+                trendSummary={props.trendSummary}
                 totalFrames={totalFrames}
                 totalFrames={totalFrames}
               />
               />
             </Sequence>
             </Sequence>
           );
           );
         }
         }
-        if (scene.sceneType === "outro") {
+        if (scene.kind === "outro") {
           return (
           return (
             <Sequence
             <Sequence
               key={scene.id}
               key={scene.id}
-              from={scene.startFrame}
-              durationInFrames={duration}
+              from={startFrame}
+              durationInFrames={durationFrames}
             >
             >
               {sceneAudio}
               {sceneAudio}
               <OutroScene
               <OutroScene
-                text={props.outro?.text || scene.narration}
-                cta={props.outro?.cta}
+                text={scene.captionOrigin ?? ""}
+                cta={scene.title}
                 totalFrames={totalFrames}
                 totalFrames={totalFrames}
               />
               />
             </Sequence>
             </Sequence>
@@ -131,14 +125,14 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
         return (
         return (
           <Sequence
           <Sequence
             key={scene.id}
             key={scene.id}
-            from={scene.startFrame}
-            durationInFrames={duration}
+            from={startFrame}
+            durationInFrames={durationFrames}
           >
           >
             {sceneAudio}
             {sceneAudio}
             <SceneComponent
             <SceneComponent
               scene={scene}
               scene={scene}
-              sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
-              totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
+              sceneIndex={props.scenes.filter((s) => s.kind === "content").indexOf(scene)}
+              totalScenes={props.scenes.filter((s) => s.kind === "content").length}
               totalFrames={totalFrames}
               totalFrames={totalFrames}
               title={props.title}
               title={props.title}
               channelName={props.channelName}
               channelName={props.channelName}
@@ -147,7 +141,7 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
         );
         );
       })}
       })}
       <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
       <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
-      {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
+      {props.template === "github-trending" && <ChapterToc layout={layout} />}
     </AbsoluteFill>
     </AbsoluteFill>
   );
   );
 };
 };
@@ -193,12 +187,12 @@ const CoverScene: React.FC<{
   template: TemplateType;
   template: TemplateType;
   title: string;
   title: string;
   subtitle?: string;
   subtitle?: string;
-  keyframes: Array<{ type: string; content: string }>;
+  cardList?: Array<{ kind?: string; desc?: string }>;
   backgroundAsset?: { id: string; filename: string };
   backgroundAsset?: { id: string; filename: string };
-  coverRepos?: RemotionScene[];
+  coverRepos?: RenderScene[];
   trendSummary?: string;
   trendSummary?: string;
   totalFrames: number;
   totalFrames: number;
-}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => {
+}> = ({ template, title, subtitle, cardList, backgroundAsset, coverRepos, trendSummary }) => {
   const accent = THEMES[template].primaryLight;
   const accent = THEMES[template].primaryLight;
   const isGithubTrending = template === "github-trending";
   const isGithubTrending = template === "github-trending";
   const { width, height } = useVideoConfig();
   const { width, height } = useVideoConfig();
@@ -210,7 +204,7 @@ const CoverScene: React.FC<{
   // landscape, vertical stack in portrait. Doubles as the opening narration.
   // landscape, vertical stack in portrait. Doubles as the opening narration.
   if (isGithubTrending) {
   if (isGithubTrending) {
     const topRepos = [...(coverRepos ?? [])]
     const topRepos = [...(coverRepos ?? [])]
-      .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
+      .sort((a, b) => (ghExt(b)!.repo.todayStars ?? 0) - (ghExt(a)!.repo.todayStars ?? 0))
       .slice(0, 6);
       .slice(0, 6);
     const primary = THEMES[template].primary;
     const primary = THEMES[template].primary;
 
 
@@ -280,7 +274,7 @@ const CoverScene: React.FC<{
               maxWidth: isPortrait ? "90%" : "94%",
               maxWidth: isPortrait ? "90%" : "94%",
             }}>
             }}>
               {topRepos.map((s, i) => {
               {topRepos.map((s, i) => {
-                const g = s.github!;
+                const g = ghExt(s)!;
                 const repo = g.repo;
                 const repo = g.repo;
                 const language = repo.language || "";
                 const language = repo.language || "";
                 const languageColor = repo.languageColor || accent;
                 const languageColor = repo.languageColor || accent;
@@ -408,10 +402,10 @@ const CoverScene: React.FC<{
             {subtitle}
             {subtitle}
           </div>
           </div>
         )}
         )}
-        {keyframes.length > 0 && (
+        {(cardList?.length ?? 0) > 0 && (
           <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
           <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
-            {keyframes.map((kf, i) => {
-              const isHighlight = kf.type === "highlight";
+            {cardList!.map((card, i) => {
+              const isHighlight = card.kind === "highlight";
               return (
               return (
                 <div key={i} style={{
                 <div key={i} style={{
                   fontSize: isHighlight ? 30 : 24,
                   fontSize: isHighlight ? 30 : 24,
@@ -420,7 +414,7 @@ const CoverScene: React.FC<{
                   fontFamily: "Noto Sans SC",
                   fontFamily: "Noto Sans SC",
                   textShadow: "0 1px 8px rgba(0,0,0,0.85)",
                   textShadow: "0 1px 8px rgba(0,0,0,0.85)",
                 }}>
                 }}>
-                  {kf.content}
+                  {card.desc}
                 </div>
                 </div>
               );
               );
             })}
             })}
@@ -502,7 +496,9 @@ const GlobalProgressBar: React.FC<{
   );
   );
 };
 };
 
 
-const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
+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
   // github-trending only: a chapter table of contents pinned just above the
   // global progress bar. Lists every repo's short name; the chapter whose
   // global progress bar. Lists every repo's short name; the chapter whose
   // [startFrame, endFrame) contains the current frame is highlighted. A faint
   // [startFrame, endFrame) contains the current frame is highlighted. A faint
@@ -513,10 +509,8 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
   const isPortrait = height > width;
   const isPortrait = height > width;
   const accent = THEMES["github-trending"].primary;
   const accent = THEMES["github-trending"].primary;
 
 
-  const chapters = scenes.filter((s) => s.sceneType === "content" && s.github);
-  const active = chapters.findIndex(
-    (c) => frame >= c.startFrame && frame < c.endFrame
-  );
+  const chapters = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
+  const active = chapters.findIndex((c) => frame >= c.startFrame && frame < c.endFrame);
 
 
   return (
   return (
     <>
     <>
@@ -548,10 +542,10 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
       >
       >
         {chapters.map((c, i) => {
         {chapters.map((c, i) => {
           const isActive = i === active;
           const isActive = i === active;
-          const repo = c.github!.repo;
-          const label = repo.name || repo.fullName || c.displayText || "";
+          const repo = ghExt(c.scene)!.repo;
+          const label = repo.name || repo.fullName || c.scene.title || "";
           return (
           return (
-            <React.Fragment key={c.id}>
+            <React.Fragment key={c.scene.id}>
               {i > 0 && (
               {i > 0 && (
                 <span
                 <span
                   style={{
                   style={{
@@ -586,51 +580,39 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
   );
   );
 };
 };
 
 
-const defaultProps: RemotionProps = {
+const defaultProps: RenderProps = {
   scenes: [
   scenes: [
     {
     {
       id: "cover",
       id: "cover",
-      sceneType: "cover",
-      startFrame: 0,
-      endFrame: 90,
-      narration: "",
-      wordTimestamps: [],
+      kind: "cover",
+      duration: 3,
       audioFilename: "",
       audioFilename: "",
-      keyframes: [
-        { type: "text", content: "文本转视频,一键生成" },
-      ],
-      layoutHint: "centered",
+      captionOrigin: "",
+      cardList: [{ kind: "text", desc: "文本转视频,一键生成" }],
     },
     },
     {
     {
       id: "scene-1",
       id: "scene-1",
-      sceneType: "content",
-      startFrame: 90,
-      endFrame: 180,
-      narration: "欢迎使用 Pipeline 视频生成工具",
-      wordTimestamps: [],
+      kind: "content",
+      duration: 3,
       audioFilename: "",
       audioFilename: "",
-      keyframes: [
-        { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
-      ],
-      layoutHint: "centered",
+      title: "欢迎使用 Pipeline",
+      captionOrigin: "欢迎使用 Pipeline 视频生成工具",
+      caption: [{ text: "欢迎使用 Pipeline 视频生成工具", duration: 3 }],
+      cardList: [{ kind: "text", desc: "支持资讯、知识、观点、营销、GitHub热榜模板" }],
     },
     },
     {
     {
       id: "outro",
       id: "outro",
-      sceneType: "outro",
-      startFrame: 180,
-      endFrame: 240,
-      narration: "感谢观看",
-      wordTimestamps: [],
+      kind: "outro",
+      duration: 2,
       audioFilename: "",
       audioFilename: "",
-      keyframes: [],
-      layoutHint: "centered",
+      captionOrigin: "感谢观看",
+      title: "点赞 | 关注",
     },
     },
   ],
   ],
   template: "news",
   template: "news",
   platform: "bilibili",
   platform: "bilibili",
   title: "Pipeline Demo",
   title: "Pipeline Demo",
   subtitle: "结构化视频生成",
   subtitle: "结构化视频生成",
-  outro: { text: "感谢观看", cta: "点赞 | 关注" },
   channelName: "Pipeline",
   channelName: "Pipeline",
   globalStyle: { tone: "formal", pace: "normal" },
   globalStyle: { tone: "formal", pace: "normal" },
 };
 };
@@ -703,9 +685,15 @@ export const RemotionRoot: React.FC = () => {
             height={dims.height}
             height={dims.height}
             defaultProps={defaultProps}
             defaultProps={defaultProps}
             calculateMetadata={async (params) => {
             calculateMetadata={async (params) => {
-              const p = params.props as unknown as RemotionProps;
+              const p = params.props as unknown as RenderProps;
+              // Visual timeline is template-computed: sum each scene's visual
+              // frames (default = audio duration; silent scenes get a floor).
+              const total = p.scenes.reduce(
+                (sum, s) => sum + sceneDurationFrames(s, 30),
+                0
+              );
               return {
               return {
-                durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
+                durationInFrames: total || 90,
                 props: params.props,
                 props: params.props,
               };
               };
             }}
             }}

+ 27 - 52
packages/templates/src/base/components/subtitle-bar.tsx

@@ -1,63 +1,42 @@
 import React from "react";
 import React from "react";
 import { useCurrentFrame, useVideoConfig } from "remotion";
 import { useCurrentFrame, useVideoConfig } from "remotion";
-import type { WordTimestamp } from "@pipeline/shared";
+import type { Caption } from "@pipeline/shared";
 
 
 interface SubtitleBarProps {
 interface SubtitleBarProps {
-  wordTimestamps: WordTimestamp[];
+  /** Pre-split captions (text + duration) from the audio module. Durations are
+   *  scene-relative seconds (0-based), matching useCurrentFrame within a Sequence. */
+  caption?: Caption[];
   style?: React.CSSProperties;
   style?: React.CSSProperties;
   fontSize?: number;
   fontSize?: number;
 }
 }
 
 
-interface Segment {
-  words: WordTimestamp[];
-  startSeconds: number;
-  endSeconds: number;
-}
-
-function groupIntoSegments(wordTimestamps: WordTimestamp[]): Segment[] {
-  if (wordTimestamps.length === 0) return [];
-
-  const segmentEnds = new Set<number>();
-  for (let i = 0; i < wordTimestamps.length; i++) {
-    const w = wordTimestamps[i].word;
-    if (/[。!?;]/.test(w)) {
-      segmentEnds.add(i);
-    } else if (/[,、,;]/.test(w) && i - (segmentEnds.size > 0 ? [...segmentEnds].pop()! : -1) >= 8) {
-      segmentEnds.add(i);
-    }
-  }
-  segmentEnds.add(wordTimestamps.length - 1);
-
-  const segments: Segment[] = [];
-  let segStart = 0;
-  for (const end of [...segmentEnds].sort((a, b) => a - b)) {
-    const words = wordTimestamps.slice(segStart, end + 1);
-    segments.push({
-      words,
-      startSeconds: words[0].startSeconds,
-      endSeconds: words[words.length - 1].endSeconds,
-    });
-    segStart = end + 1;
-  }
-  return segments;
-}
-
-export const SubtitleBar: React.FC<SubtitleBarProps> = ({
-  wordTimestamps,
-  style,
-  fontSize,
-}) => {
+/**
+ * Subtitle bar — renders the caption whose cumulative [start, end] range contains
+ * the current scene-relative time; falls back to the last caption during any
+ * tail (e.g. when the scene is padded beyond the audio). The audio module now
+ * pre-splits captions (porting the old runtime groupIntoSegments), so this just
+ * picks the active one — identical on-screen timing to before.
+ */
+export const SubtitleBar: React.FC<SubtitleBarProps> = ({ caption, style, fontSize }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
   const { fps } = useVideoConfig();
   const { fps } = useVideoConfig();
   const currentTime = frame / fps;
   const currentTime = frame / fps;
 
 
-  const segments = React.useMemo(() => groupIntoSegments(wordTimestamps), [wordTimestamps]);
+  const captions = caption ?? [];
+  if (captions.length === 0) return null;
 
 
-  const currentSegment = segments.find(
-    (s) => currentTime >= s.startSeconds && currentTime <= s.endSeconds
-  ) ?? segments[segments.length - 1];
-
-  if (!currentSegment) return null;
+  let cum = 0;
+  let active: Caption | undefined;
+  for (const c of captions) {
+    const start = cum;
+    const end = cum + c.duration;
+    if (currentTime >= start && currentTime <= end) {
+      active = c;
+      break;
+    }
+    cum = end;
+  }
+  if (!active) active = captions[captions.length - 1];
 
 
   return (
   return (
     <div
     <div
@@ -95,11 +74,7 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
             overflow: "hidden",
             overflow: "hidden",
           }}
           }}
         >
         >
-          {currentSegment.words
-            .filter((w) => !/[。,!?\s]/.test(w.word))
-            .map((w, i) => (
-              <span key={i}>{w.word}</span>
-          ))}
+          {active.text}
         </span>
         </span>
       </div>
       </div>
     </div>
     </div>

+ 16 - 14
packages/templates/src/github-trending/index.tsx

@@ -8,6 +8,7 @@ import {
   interpolate,
   interpolate,
 } from "remotion";
 } from "remotion";
 import type { RemotionScene } from "../Root";
 import type { RemotionScene } from "../Root";
+import type { RenderSegmentExtension } from "@pipeline/shared";
 import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
 import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
 import { SubtitleBar } from "../base/components/subtitle-bar";
 import { SubtitleBar } from "../base/components/subtitle-bar";
 import { Watermark } from "../base/components/watermark";
 import { Watermark } from "../base/components/watermark";
@@ -29,12 +30,13 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   channelName,
   channelName,
 }) => {
 }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
-  const { width, height } = useVideoConfig();
+  const { width, height, durationInFrames } = useVideoConfig();
   const isPortrait = height > width;
   const isPortrait = height > width;
   const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
   const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
-  const github = scene.github;
+  const github: RenderSegmentExtension | undefined =
+    scene.extension?.type === "github-trending" ? scene.extension : undefined;
 
 
-  const sceneDur = scene.endFrame - scene.startFrame;
+  const sceneDur = durationInFrames;
   const fadeOpacity = interpolate(
   const fadeOpacity = interpolate(
     frame,
     frame,
     [0, 8, sceneDur - 8, sceneDur],
     [0, 8, sceneDur - 8, sceneDur],
@@ -42,10 +44,10 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
   );
 
 
-  // Social preview image is images[0], star history is images[1] (per LLM
-  // prompt ordering — see packages/shared/src/llm/prompts/parse-text.ts).
-  const socialPreview = scene.images?.[0];
-  const starHistory = scene.images?.[1];
+  // Named images from the template 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 pad = isPortrait ? 48 : 80;
   const footerReserved = isPortrait ? 500 : 140;
   const footerReserved = isPortrait ? 500 : 140;
@@ -62,7 +64,7 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   const languageColor = repo?.languageColor || palette.accent;
   const languageColor = repo?.languageColor || palette.accent;
   const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
   const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
   const license = repo?.license || "";
   const license = repo?.license || "";
-  const fullName = repo?.fullName || scene.displayText || "";
+  const fullName = repo?.fullName || scene.title || "";
 
 
   if (!github) {
   if (!github) {
     return (
     return (
@@ -72,10 +74,10 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
           alignItems: "center", justifyContent: "center", padding: pad,
           alignItems: "center", justifyContent: "center", padding: pad,
         }}>
         }}>
           <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
           <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
-            {scene.displayText || scene.narration}
+            {scene.title || scene.captionOrigin}
           </div>
           </div>
         </div>
         </div>
-        {scene.wordTimestamps.length > 0 && <SubtitleBar wordTimestamps={scene.wordTimestamps} />}
+        {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
         <Watermark text={channelName} />
         <Watermark text={channelName} />
       </AbsoluteFill>
       </AbsoluteFill>
     );
     );
@@ -188,9 +190,9 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
         </>
         </>
       )}
       )}
 
 
-      {scene.wordTimestamps.length > 0 && (
+      {(scene.caption?.length ?? 0) > 0 && (
         <SubtitleBar
         <SubtitleBar
-          wordTimestamps={scene.wordTimestamps}
+          caption={scene.caption}
           style={isPortrait ? { bottom: 380 } : undefined}
           style={isPortrait ? { bottom: 380 } : undefined}
           fontSize={isPortrait ? 56 : undefined}
           fontSize={isPortrait ? 56 : undefined}
         />
         />
@@ -325,7 +327,7 @@ interface PortraitContentProps {
   palette: { from: string; to: string; accent: string };
   palette: { from: string; to: string; accent: string };
   socialPreview?: { filename: string };
   socialPreview?: { filename: string };
   starHistory?: { filename: string };
   starHistory?: { filename: string };
-  github: NonNullable<RemotionScene["github"]>;
+  github: RenderSegmentExtension;
 }
 }
 
 
 // PortraitContent uses flex:1 to fill remaining height after the header
 // PortraitContent uses flex:1 to fill remaining height after the header
@@ -387,7 +389,7 @@ interface LandscapeContentProps {
   palette: { from: string; to: string; accent: string };
   palette: { from: string; to: string; accent: string };
   socialPreview?: { filename: string };
   socialPreview?: { filename: string };
   starHistory?: { filename: string };
   starHistory?: { filename: string };
-  github: NonNullable<RemotionScene["github"]>;
+  github: RenderSegmentExtension;
 }
 }
 
 
 const LandscapeContent: React.FC<LandscapeContentProps> = ({
 const LandscapeContent: React.FC<LandscapeContentProps> = ({

+ 9 - 8
packages/templates/src/knowledge/index.tsx

@@ -33,12 +33,13 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
   channelName,
   channelName,
 }) => {
 }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
+  const { durationInFrames } = useVideoConfig();
   const colors = THEMES.knowledge;
   const colors = THEMES.knowledge;
-  const visualText = scene.displayText ?? scene.narration;
+  const visualText = scene.title ?? scene.captionOrigin ?? "";
 
 
   const fadeOpacity = interpolate(
   const fadeOpacity = interpolate(
     frame,
     frame,
-    [0, 10, scene.endFrame - scene.startFrame - 10, scene.endFrame - scene.startFrame],
+    [0, 10, durationInFrames - 10, durationInFrames],
     [0, 1, 1, 0],
     [0, 1, 1, 0],
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
   );
@@ -103,18 +104,18 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
           delay={5}
           delay={5}
         />
         />
 
 
-        {scene.keyframes.length > 0 && (
+        {((scene.cardList?.length ?? 0) > 0) && (
           <KeyPointCard
           <KeyPointCard
-            points={scene.keyframes
-              .filter((kf) => kf.type === "text" || kf.type === "highlight")
-              .map((kf) => kf.content)}
+            points={(scene.cardList ?? [])
+              .filter((card) => card.kind === "text" || card.kind === "highlight")
+              .map((card) => card.desc ?? "")}
             color={colors.primaryLight}
             color={colors.primaryLight}
           />
           />
         )}
         )}
       </div>
       </div>
 
 
-      {scene.wordTimestamps.length > 0 && (
-        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar caption={scene.caption} />
       )}
       )}
 
 
       <Watermark text={channelName} />
       <Watermark text={channelName} />

+ 10 - 9
packages/templates/src/marketing/index.tsx

@@ -33,19 +33,20 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
   channelName,
   channelName,
 }) => {
 }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
+  const { durationInFrames } = useVideoConfig();
   const colors = THEMES.marketing;
   const colors = THEMES.marketing;
-  const visualText = scene.displayText ?? scene.narration;
+  const visualText = scene.title ?? scene.captionOrigin ?? "";
 
 
   const fadeOpacity = interpolate(
   const fadeOpacity = interpolate(
     frame,
     frame,
-    [0, 8, scene.endFrame - scene.startFrame - 8, scene.endFrame - scene.startFrame],
+    [0, 8, durationInFrames - 8, durationInFrames],
     [0, 1, 1, 0],
     [0, 1, 1, 0],
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
   );
 
 
   const isLastScene = sceneIndex === totalScenes - 1;
   const isLastScene = sceneIndex === totalScenes - 1;
-  const featureKeyframes = scene.keyframes.filter(
-    (kf) => kf.type === "text" || kf.type === "highlight"
+  const featureCards = (scene.cardList ?? []).filter(
+    (card) => card.kind === "text" || card.kind === "highlight"
   );
   );
 
 
   return (
   return (
@@ -107,7 +108,7 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
           // CTA scene
           // CTA scene
           <CTAOverlay
           <CTAOverlay
             text={visualText}
             text={visualText}
-            subtitle={featureKeyframes[0]?.content}
+            subtitle={featureCards[0]?.desc}
             color={colors.primaryLight}
             color={colors.primaryLight}
           />
           />
         ) : (
         ) : (
@@ -119,9 +120,9 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
               fontWeight={700}
               fontWeight={700}
               delay={5}
               delay={5}
             />
             />
-            {featureKeyframes.length > 0 && (
+            {featureCards.length > 0 && (
               <ProductShowcase
               <ProductShowcase
-                features={featureKeyframes.map((kf) => kf.content)}
+                features={featureCards.map((card) => card.desc ?? "")}
                 accentColor={colors.primaryLight}
                 accentColor={colors.primaryLight}
               />
               />
             )}
             )}
@@ -129,8 +130,8 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
         )}
         )}
       </div>
       </div>
 
 
-      {scene.wordTimestamps.length > 0 && (
-        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar caption={scene.caption} />
       )}
       )}
 
 
       <Watermark text={channelName} />
       <Watermark text={channelName} />

+ 13 - 12
packages/templates/src/news/index.tsx

@@ -34,18 +34,19 @@ const NewsScene: React.FC<NewsSceneProps> = ({
   channelName,
   channelName,
 }) => {
 }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
-  const { fps } = useVideoConfig();
+  const { fps, durationInFrames } = useVideoConfig();
   const colors = THEMES.news;
   const colors = THEMES.news;
 
 
   // Scene-level fade
   // Scene-level fade
   const fadeOpacity = interpolate(
   const fadeOpacity = interpolate(
     frame,
     frame,
-    [0, 8, scene.endFrame - scene.startFrame - 8, scene.endFrame - scene.startFrame],
+    [0, 8, durationInFrames - 8, durationInFrames],
     [0, 1, 1, 0],
     [0, 1, 1, 0],
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
   );
 
 
-  const visualText = scene.displayText ?? scene.narration;
+  const visualText = scene.title ?? scene.captionOrigin ?? "";
+  const cards = scene.cardList ?? [];
 
 
   return (
   return (
     <AbsoluteFill style={{ opacity: fadeOpacity }}>
     <AbsoluteFill style={{ opacity: fadeOpacity }}>
@@ -127,10 +128,10 @@ const NewsScene: React.FC<NewsSceneProps> = ({
             >
             >
               {visualText}
               {visualText}
             </div>
             </div>
-            {scene.keyframes.map((kf, i) => (
+            {cards.map((card, i) => (
               <HeadlineCard
               <HeadlineCard
                 key={i}
                 key={i}
-                headline={kf.content}
+                headline={card.desc ?? ""}
                 index={i}
                 index={i}
               />
               />
             ))}
             ))}
@@ -156,12 +157,12 @@ const NewsScene: React.FC<NewsSceneProps> = ({
               {visualText}
               {visualText}
             </div>
             </div>
             <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
             <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
-              {scene.keyframes
-                .filter((kf) => kf.type === "text" || kf.type === "highlight")
-                .map((kf, i) => (
+              {cards
+                .filter((card) => card.kind === "text" || card.kind === "highlight")
+                .map((card, i) => (
                   <HeadlineCard
                   <HeadlineCard
                     key={i}
                     key={i}
-                    headline={kf.content}
+                    headline={card.desc ?? ""}
                     index={i}
                     index={i}
                   />
                   />
                 ))}
                 ))}
@@ -171,13 +172,13 @@ const NewsScene: React.FC<NewsSceneProps> = ({
       </div>
       </div>
 
 
       {/* Subtitle bar */}
       {/* Subtitle bar */}
-      {scene.wordTimestamps.length > 0 && (
-        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar caption={scene.caption} />
       )}
       )}
 
 
       {/* News ticker */}
       {/* News ticker */}
       <NewsTicker
       <NewsTicker
-        headlines={scene.keyframes.map((kf) => kf.content)}
+        headlines={cards.map((card) => card.desc ?? "")}
       />
       />
 
 
       <Watermark text={channelName} />
       <Watermark text={channelName} />

+ 9 - 8
packages/templates/src/opinion/index.tsx

@@ -32,12 +32,13 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
   channelName,
   channelName,
 }) => {
 }) => {
   const frame = useCurrentFrame();
   const frame = useCurrentFrame();
+  const { durationInFrames } = useVideoConfig();
   const colors = THEMES.opinion;
   const colors = THEMES.opinion;
-  const visualText = scene.displayText ?? scene.narration;
+  const visualText = scene.title ?? scene.captionOrigin ?? "";
 
 
   const fadeOpacity = interpolate(
   const fadeOpacity = interpolate(
     frame,
     frame,
-    [0, 10, scene.endFrame - scene.startFrame - 10, scene.endFrame - scene.startFrame],
+    [0, 10, durationInFrames - 10, durationInFrames],
     [0, 1, 1, 0],
     [0, 1, 1, 0],
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
   );
@@ -111,12 +112,12 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
               fontWeight={500}
               fontWeight={500}
               delay={5}
               delay={5}
             />
             />
-            {scene.keyframes
-              .filter((kf) => kf.type === "text" || kf.type === "highlight")
-              .map((kf, i) => (
+            {(scene.cardList ?? [])
+              .filter((card) => card.kind === "text" || card.kind === "highlight")
+              .map((card, i) => (
                 <QuoteBlock
                 <QuoteBlock
                   key={i}
                   key={i}
-                  quote={kf.content}
+                  quote={card.desc ?? ""}
                   color={colors.primaryLight}
                   color={colors.primaryLight}
                 />
                 />
               ))}
               ))}
@@ -124,8 +125,8 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
         )}
         )}
       </div>
       </div>
 
 
-      {scene.wordTimestamps.length > 0 && (
-        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar caption={scene.caption} />
       )}
       )}
 
 
       <Watermark text={channelName} />
       <Watermark text={channelName} />

+ 26 - 0
packages/text/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@pipeline/text",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@pipeline/shared": "workspace:*",
+    "@pipeline/collect": "workspace:*",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

+ 200 - 0
packages/text/src/assemble.ts

@@ -0,0 +1,200 @@
+import {
+  normalizeCountsForTTS,
+  formatChineseDate,
+  type VideoInput,
+  type VideoDocument,
+  type VideoSegment,
+  type DocumentImage,
+  type SegmentExtension,
+  type RepoMeta,
+  type PublishMeta,
+  type TemplateType,
+  VideoDocumentSchema,
+} from "@pipeline/shared";
+import { getTimezone } from "@pipeline/shared/node";
+
+/**
+ * Assembly layer — turns a validated VideoInput (+ typed repo metadata from the
+ * data source) into the canonical VideoDocument. Ports the scene-building
+ * section of the legacy parse stage, plus the typed-repos merge that makes the
+ * data source the authoritative channel for github-trending metadata/images.
+ *
+ * Deterministic: no LLM, no network. The same VideoInput + repos always yields
+ * the same VideoDocument, so output is unaffected by the refactor.
+ */
+export interface AssembledDocument {
+  doc: VideoDocument;
+  /** Posting metadata (title/description/tags) for the sidecar manifest — kept
+   *  OUT of the VideoDocument so it stays pure video content. */
+  publish?: PublishMeta;
+}
+
+export function assembleDocument(
+  videoInput: VideoInput,
+  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") {
+    videoInput = {
+      ...videoInput,
+      title: "GitHub 每日热榜",
+      subtitle: formatChineseDate(new Date(), getTimezone()),
+    };
+  }
+
+  // Typed repo lookup by fullName — the authoritative metadata channel. The
+  // data source (collector) owns repo metadata; whatever the LLM copied from the
+  // legacy comment blocks is overridden here. github-trending repo data lives in
+  // the per-template `extension` (NOT as generic images or a generic `github`
+  // field) — the renderer derives the social-preview + star-history images from
+  // the extension, so the generic `images` list stays free of template quirks.
+  const repoByFullName = new Map<string, RepoMeta>();
+  for (const r of repos) repoByFullName.set(r.fullName.toLowerCase(), r);
+  const mergeRepo = (
+    s: VideoInput["scenes"][number]
+  ): { extension?: SegmentExtension; images?: DocumentImage[] } => {
+    if (!s.github) {
+      // Non-github scene: pass through only generic image sources.
+      const imgs = (s.images ?? [])
+        .map(({ path, url, query }) => ({ path, url, query }))
+        .filter((i) => i.path || i.url || i.query);
+      return { images: imgs.length ? imgs : undefined };
+    }
+    const key = (s.github.repo.fullName ?? s.displayText ?? "").toString().toLowerCase();
+    const typed = repoByFullName.get(key) ?? s.github.repo;
+    return {
+      extension: {
+        type: "github-trending",
+        repo: typed,
+        highlights: s.github.highlights,
+        intro: s.github.intro,
+        review: s.github.review,
+        // Template-declared images (named object): social preview (resolved via
+        // the repo crawler) + star history (URL). Explicit in the JSON so the
+        // template controls them; the renderer resolves each by name.
+        images: {
+          socialPreview: `${typed.owner}/${typed.name}`,
+          starHistory: `https://api.star-history.com/svg?repos=${typed.fullName}&type=Date`,
+        },
+      },
+    };
+  };
+
+  const segments: VideoSegment[] = [];
+  let index = 0;
+
+  // --- 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";
+  const coverKeyframes = inheritFromFirstScene
+    ? (coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3))
+    : (coverInput?.keyframes ?? []);
+  const firstSceneCoverImage = inheritFromFirstScene
+    ? (firstScene?.images ?? []).find((img) => img.path || img.url || img.query)
+    : undefined;
+  const coverImages = coverInput
+    ? [
+        ...(coverInput.imagePath ? [{ path: coverInput.imagePath }] : []),
+        ...(coverInput.imageUrl ? [{ url: coverInput.imageUrl }] : []),
+        ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
+      ]
+    : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
+
+  // Cover opens: greeting → today'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).
+  const trend = (videoInput.trendSummary ?? "")
+    .trim()
+    .replace(/[。!?.!?\s]+$/u, "");
+  const coverNarration = template === "github-trending"
+    ? trend
+      ? `大家好,今天${trend}。下面进入项目详解。`
+      : "大家好,下面进入项目详解。"
+    : "";
+
+  segments.push({
+    id: "cover",
+    kind: "cover",
+    index: index++,
+    captionOrigin: coverNarration || undefined,
+    cardList: coverKeyframes.length
+      ? coverKeyframes.map((kf) => ({ kind: kf.type, desc: kf.content }))
+      : undefined,
+    images: coverImages.length ? (coverImages as VideoSegment["images"]) : undefined,
+    duration: 1,
+  });
+
+  // --- 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"
+    ? videoInput.scenes
+        .filter((s) => s.github)
+        .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
+        .slice(0, 6)
+    : videoInput.scenes;
+
+  for (const s of contentInputs) {
+    const { extension, images } = mergeRepo(s);
+    segments.push({
+      id: s.id,
+      kind: "content",
+      index: index++,
+      title: s.displayText ?? s.title,
+      captionOrigin: s.narration,
+      cardList: (s.keyframes ?? []).map((kf) => ({ kind: kf.type, desc: kf.content })),
+      images,
+      layoutHint: s.layoutHint,
+      extension,
+      speed: s.speed,
+      duration: s.duration,
+    });
+  }
+
+  // --- Outro (optional) ---
+  if (videoInput.outro) {
+    segments.push({
+      id: "outro",
+      kind: "outro",
+      index: index++,
+      title: videoInput.outro.cta,
+      captionOrigin: videoInput.outro.narration || videoInput.outro.text,
+    });
+  }
+
+  // Expand "Nk" star/fork counts in every narration to spoken Chinese before
+  // the audio module reads them aloud. Applied to captionOrigin only — visual
+  // card counts (formatCount) are unaffected.
+  for (const seg of segments) {
+    if (seg.captionOrigin) seg.captionOrigin = normalizeCountsForTTS(seg.captionOrigin);
+  }
+
+  const doc: VideoDocument = {
+    version: "2.0",
+    type: "ppt",
+    template,
+    config: {
+      globalStyle: videoInput.globalStyle,
+    },
+    meta: {
+      title: videoInput.title,
+      subtitle: videoInput.subtitle ?? videoInput.scenes[0]?.title,
+      summary: videoInput.summary || undefined,
+      trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
+    },
+    data: segments,
+  };
+
+  // publish is NOT part of the video content — return it as a separate product
+  // (posting metadata for the sidecar manifest), so VideoDocument stays pure.
+  return { doc: VideoDocumentSchema.parse(doc), publish: videoInput.publish };
+}

+ 22 - 0
packages/text/src/collect.ts

@@ -0,0 +1,22 @@
+import { getCollector } from "@pipeline/collect";
+import type { CollectResult } from "@pipeline/collect";
+import type { CollectedSource, TextModuleInput } from "./types.js";
+
+/**
+ * Data-source layer of the text module — kept separate from the LLM text logic
+ * (per docs/项目解耦: 数据源与 AI 文字逻辑拆分) so future mixed data sources can
+ * feed the same downstream layers. Returns the collector's markdown `content`
+ * (LLM input) plus the typed `repos` channel (authoritative metadata the
+ * assembly layer merges onto scenes). Returns null when no source is configured.
+ */
+export async function collectSource(input: TextModuleInput): Promise<CollectedSource | null> {
+  if (!input.source) return null;
+  const collector = getCollector(input.source, input.collectorConfig);
+  const result: CollectResult = await collector.collect({ args: input.sourceArgs });
+  if (result.type === "json") {
+    // A collector that emits a ready-made VideoInput as JSON — hand it through
+    // as text so the generate layer's detectInputFormat path consumes it.
+    return { content: JSON.stringify(result.content), repos: [] };
+  }
+  return { content: result.content, repos: result.repos ?? [] };
+}

+ 80 - 0
packages/text/src/generate.ts

@@ -0,0 +1,80 @@
+import {
+  VideoInputSchema,
+  detectInputFormat,
+  LLMClient,
+  getParsePrompt,
+  type VideoInput,
+  type TemplateType,
+} from "@pipeline/shared";
+import { applyLengthLimits, sanitizeVideoInput, JSON_TRUNCATION_NUDGE } from "./postprocess.js";
+import type { TextModuleLlmConfig } from "./types.js";
+
+/**
+ * LLM text layer — turns raw text (from a collector or the user) into a
+ * validated, sanitized VideoInput. Ports the input-normalization section of the
+ * legacy parse stage verbatim:
+ *  - if the text is already valid VideoInputSchema JSON, use it directly;
+ *  - otherwise call the LLM with the template/source parse prompt, stripping
+ *    code fences, retrying once with a conciseness nudge on JSON-parse failure
+ *    (finish_reason="length" truncation is common for large trending lists);
+ *  - clip over-budget fields and strip unsupported glyphs before returning.
+ *
+ * Reuses the proven shared prompt (getParsePrompt) unchanged — the data-source
+ * metadata is merged authoritatively in the assembly layer, so the prompt does
+ * not need to change for the typed-repos flow.
+ */
+export async function generateVideoInput(
+  text: string,
+  template: TemplateType,
+  llm: TextModuleLlmConfig,
+  source: string | undefined,
+  skipLlm?: boolean
+): Promise<VideoInput> {
+  const detected = detectInputFormat(text);
+
+  if (detected.format === "valid-schema") {
+    return sanitizeVideoInput(detected.parsed!);
+  }
+
+  if (skipLlm || !(llm.apiKey || process.env.OPENAI_API_KEY)) {
+    throw new Error(
+      `Input is not valid VideoInputSchema JSON and AI processing is disabled. ` +
+        `Provide structured JSON matching VideoInputSchema or enable LLM processing (set OPENAI_API_KEY or remove --skip-llm).`
+    );
+  }
+
+  const client = new LLMClient(llm);
+  const systemPrompt = getParsePrompt(template, source);
+
+  const stripFences = (s: string) =>
+    s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
+
+  let aiParsed: unknown;
+  let lastFinish: string | null = null;
+  let lastRaw = "";
+  for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
+    const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
+    const { content, finishReason } = await client.chat(systemPrompt, userMessage);
+    lastFinish = finishReason;
+    lastRaw = stripFences(content);
+    try {
+      aiParsed = JSON.parse(lastRaw);
+    } catch {
+      // not valid JSON yet — fall through to retry, or to the final error below
+    }
+  }
+  if (aiParsed === undefined) {
+    throw new Error(
+      `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
+        `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
+    );
+  }
+
+  aiParsed = applyLengthLimits(aiParsed, template);
+
+  const validationResult = VideoInputSchema.safeParse(aiParsed);
+  if (!validationResult.success) {
+    throw new Error(`AI output does not match VideoInputSchema: ${validationResult.error.message}`);
+  }
+  return sanitizeVideoInput(validationResult.data);
+}

+ 56 - 0
packages/text/src/index.ts

@@ -0,0 +1,56 @@
+import type { VideoDocument, PublishMeta, RepoMeta } from "@pipeline/shared";
+import { collectSource } from "./collect.js";
+import { generateVideoInput } from "./generate.js";
+import { assembleDocument } from "./assemble.js";
+import type { TextModuleInput } from "./types.js";
+
+export type { TextModuleInput, TextModuleLlmConfig, CollectedSource } from "./types.js";
+export { generateVideoInput } from "./generate.js";
+export { assembleDocument } from "./assemble.js";
+export type { AssembledDocument } from "./assemble.js";
+
+/** Text module output: the pure VideoDocument (video content) PLUS the posting
+ *  metadata `publish`, kept separate so the document contract stays pure. */
+export interface TextModuleOutput {
+  doc: VideoDocument;
+  /** Posting metadata (title/description/tags) for the sidecar manifest — NOT
+   *  rendered into the video, NOT part of VideoDocument. */
+  publish?: PublishMeta;
+}
+
+/**
+ * AI 文字模块(Module 1)入口 — 协调数据源与多步 LLM,产出平台无关的
+ * VideoDocument(含 caption_origin / card_list,尚无音频)+ 独立的发布元信息 publish。
+ *
+ * 三层分离(数据源 / LLM 文字 / 装配),每层职责单一,为未来混合数据源留口子:
+ *   1. collect  — 数据源层(@pipeline/collect),返回 markdown + 类型化 repos
+ *   2. generate — LLM 文字层,text → VideoInput(detect / LLM / 校验 / 清洗)
+ *   3. assemble — 装配层,VideoInput + repos → {doc, publish}(确定性后处理 +
+ *                 类型化元数据合并)。这一步无 LLM、无网络,可单独单测。
+ */
+export async function generateDocument(input: TextModuleInput): Promise<TextModuleOutput> {
+  // 1. Data source (optional). When a source is configured, collect first; the
+  //    collected markdown becomes the LLM input and the typed repos feed the
+  //    authoritative metadata merge.
+  let text = input.text ?? "";
+  let repos: RepoMeta[] = [];
+  if (input.source) {
+    const collected = await collectSource(input);
+    if (collected) {
+      text = collected.content;
+      repos = collected.repos;
+    }
+  }
+
+  // 2. LLM text generation → validated VideoInput.
+  const videoInput = await generateVideoInput(
+    text,
+    input.template,
+    input.llm,
+    input.source,
+    input.skipLlm
+  );
+
+  // 3. Assemble the canonical VideoDocument + separate publish metadata.
+  return assembleDocument(videoInput, input.template, repos);
+}

+ 158 - 0
packages/text/src/postprocess.ts

@@ -0,0 +1,158 @@
+import {
+  stripUnsupportedGlyphs,
+  clipToLength,
+  type VideoInput,
+} from "@pipeline/shared";
+
+/**
+ * Deterministic post-processing of LLM output, ported verbatim from the legacy
+ * parse stage (packages/core/src/stages/parse.ts). Kept byte-for-byte so video
+ * output is unaffected by the refactor.
+ */
+
+/** Appended on the retry attempt when the first LLM JSON failed to parse. */
+export const JSON_TRUNCATION_NUDGE =
+  "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
+
+/**
+ * Clip LLM-generated text fields to their schema-enforced maximums before
+ * 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.
+ */
+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)
+    ? { ...(input as any) }
+    : input;
+  if (!Array.isArray((root as any).scenes)) return input;
+
+  (root as any).scenes = (root as any).scenes.map((scene: any) => {
+    if (!scene || typeof scene !== "object") return scene;
+    const next: any = { ...scene };
+    const narration = template === "github-trending"
+      ? stripLeadingGreeting(scene.narration)
+      : scene.narration;
+    next.narration = clipToLength(narration, 200);
+    if (scene.github && typeof scene.github === "object") {
+      next.github = {
+        ...scene.github,
+        highlights: clipToLength(scene.github.highlights, 30),
+        intro: clipToLength(scene.github.intro, 200),
+        review: clipToLength(scene.github.review, 30),
+      };
+    }
+    return next;
+  });
+
+  if (template === "github-trending") {
+    if (Array.isArray((root as any).coverTags)) {
+      (root as any).coverTags = (root as any).coverTags
+        .filter((t: any) => typeof t === "string" && t.trim())
+        .map((t: any) => t.trim().slice(0, 12))
+        .slice(0, 5);
+    }
+    if (typeof (root as any).trendSummary === "string") {
+      (root as any).trendSummary = clipToLength((root as any).trendSummary, 40);
+    }
+    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;
+}
+
+/** Common Chinese greeting prefixes that open a narration. */
+const GREETING_PATTERNS = [
+  /^[大各]位(?:好|大大|朋友们)?[,,!!\s]+/,
+  /^大家(?:好|朋友们)?[,,!!\s]+/,
+  /^哈喽[,,!!\s]+/,
+  /^嗨[,,!!\s]+/,
+  /^早(?:上)?好[,,!!\s]+/,
+  /^下(?:午)?好[,,!!\s]+/,
+  /^晚(?:上)?好[,,!!\s]+/,
+];
+
+/** Show-opener clauses that reference the day / show / format instead of the project. */
+const META_OPENER_PATTERNS = [
+  /^(?:今天|本(?:周|期|次))[^,,。!!.]*?(?:热榜|速览|榜单|节目|频道|播报|速递|盘点|精选|专栏|特辑)[^,,。!!.]*?[,,。!!.]\s*/,
+  /^今天[为给][^,,。!!.]*?[,,。!!.]\s*/,
+];
+
+export function stripLeadingGreeting(s: string | undefined): string | undefined {
+  if (typeof s !== "string" || s.length === 0) return s;
+  let out = s;
+  for (const re of GREETING_PATTERNS) {
+    out = out.replace(re, "");
+  }
+  for (let i = 0; i < 3; i++) {
+    let next = out;
+    for (const re of META_OPENER_PATTERNS) {
+      next = next.replace(re, "");
+    }
+    if (next === out) break;
+    out = next;
+  }
+  return out;
+}
+
+/** Strip emoji/decorative glyphs from every visible-text field of a VideoInput. */
+export function sanitizeVideoInput(input: VideoInput): VideoInput {
+  const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
+    Array.isArray(kfs)
+      ? kfs.map((kf) => ({ ...kf, content: stripUnsupportedGlyphs(kf.content ?? "") }))
+      : kfs;
+
+  return {
+    ...input,
+    title: stripUnsupportedGlyphs(input.title ?? ""),
+    subtitle: input.subtitle ? stripUnsupportedGlyphs(input.subtitle) : input.subtitle,
+    summary: input.summary ? stripUnsupportedGlyphs(input.summary) : input.summary,
+    coverTags: Array.isArray(input.coverTags)
+      ? 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,
+    scenes: input.scenes.map((s) => ({
+      ...s,
+      title: s.title ? stripUnsupportedGlyphs(s.title) : s.title,
+      narration: stripUnsupportedGlyphs(s.narration ?? ""),
+      displayText: s.displayText ? stripUnsupportedGlyphs(s.displayText) : s.displayText,
+      keyframes: cleanKeyframes(s.keyframes),
+    })),
+    outro: input.outro
+      ? {
+          ...input.outro,
+          text: stripUnsupportedGlyphs(input.outro.text ?? ""),
+          narration: input.outro.narration
+            ? stripUnsupportedGlyphs(input.outro.narration)
+            : input.outro.narration,
+          cta: input.outro.cta ? stripUnsupportedGlyphs(input.outro.cta) : input.outro.cta,
+        }
+      : input.outro,
+  };
+}

+ 26 - 0
packages/text/src/types.ts

@@ -0,0 +1,26 @@
+import type { RepoMeta, TemplateType } from "@pipeline/shared";
+
+export interface TextModuleLlmConfig {
+  baseURL?: string;
+  apiKey?: string;
+  model: string;
+}
+
+export interface TextModuleInput {
+  template: TemplateType;
+  /** Raw text input (when no data source). May be valid VideoInputSchema JSON or free text. */
+  text?: string;
+  /** Data source name (e.g. "github-trending"). When set, the module collects first. */
+  source?: string;
+  sourceArgs?: Record<string, string>;
+  /** Collector config block (config.collect[source]) — passed to the collector factory. */
+  collectorConfig?: Record<string, any>;
+  llm: TextModuleLlmConfig;
+  skipLlm?: boolean;
+}
+
+/** What the data-source layer hands to the LLM/assembly layers. */
+export interface CollectedSource {
+  content: string;
+  repos: RepoMeta[];
+}

+ 13 - 0
packages/text/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "types": ["node"]
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../shared" },
+    { "path": "../collect" }
+  ]
+}

+ 54 - 3
pnpm-lock.yaml

@@ -57,6 +57,9 @@ importers:
       '@pipeline/collect':
       '@pipeline/collect':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../../packages/collect
         version: link:../../packages/collect
+      '@pipeline/core':
+        specifier: workspace:*
+        version: link:../../packages/core
       '@pipeline/shared':
       '@pipeline/shared':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../../packages/shared
         version: link:../../packages/shared
@@ -95,6 +98,22 @@ importers:
         specifier: ^5.8.0
         specifier: ^5.8.0
         version: 5.9.3
         version: 5.9.3
 
 
+  packages/audio:
+    dependencies:
+      '@pipeline/shared':
+        specifier: workspace:*
+        version: link:../shared
+      '@pipeline/tts':
+        specifier: workspace:*
+        version: link:../tts
+      zod:
+        specifier: ^3.24.0
+        version: 3.25.76
+    devDependencies:
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.19
+
   packages/collect:
   packages/collect:
     dependencies:
     dependencies:
       '@pipeline/shared':
       '@pipeline/shared':
@@ -106,6 +125,25 @@ importers:
         version: 22.19.19
         version: 22.19.19
 
 
   packages/core:
   packages/core:
+    dependencies:
+      '@pipeline/audio':
+        specifier: workspace:*
+        version: link:../audio
+      '@pipeline/renderer':
+        specifier: workspace:*
+        version: link:../renderer
+      '@pipeline/shared':
+        specifier: workspace:*
+        version: link:../shared
+      '@pipeline/text':
+        specifier: workspace:*
+        version: link:../text
+    devDependencies:
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.19
+
+  packages/renderer:
     dependencies:
     dependencies:
       '@pipeline/shared':
       '@pipeline/shared':
         specifier: workspace:*
         specifier: workspace:*
@@ -113,9 +151,6 @@ importers:
       '@pipeline/templates':
       '@pipeline/templates':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../templates
         version: link:../templates
-      '@pipeline/tts':
-        specifier: workspace:*
-        version: link:../tts
       '@remotion/bundler':
       '@remotion/bundler':
         specifier: ^4.0.0
         specifier: ^4.0.0
         version: 4.0.467(@swc/helpers@0.5.15)(postcss@8.5.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
         version: 4.0.467(@swc/helpers@0.5.15)(postcss@8.5.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -182,6 +217,22 @@ importers:
         specifier: ^19.0.0
         specifier: ^19.0.0
         version: 19.2.3(@types/react@19.2.15)
         version: 19.2.3(@types/react@19.2.15)
 
 
+  packages/text:
+    dependencies:
+      '@pipeline/collect':
+        specifier: workspace:*
+        version: link:../collect
+      '@pipeline/shared':
+        specifier: workspace:*
+        version: link:../shared
+      zod:
+        specifier: ^3.24.0
+        version: 3.25.76
+    devDependencies:
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.19
+
   packages/tts:
   packages/tts:
     dependencies:
     dependencies:
       '@pipeline/shared':
       '@pipeline/shared':

+ 103 - 0
scripts/dump-video-document.mjs

@@ -0,0 +1,103 @@
+// 只跑【文字 + 音频】两模块(不调 Remotion、不需要 Chrome),把 VideoDocument 契约
+// 导出成 JSON,并打印一份人读摘要。用于快速验证重构后的契约与口播/卡片/字幕。
+//
+// 用法: node scripts/dump-video-document.mjs
+//   TEMPLATE=knowledge INPUT=test/fixtures/sample-knowledge.json node scripts/dump-video-document.mjs
+//   NO_TTS=1 node scripts/dump-video-document.mjs          # 跳过真实 TTS(用静音估算时长)
+//
+// 需要的:.env 里的 LLM key(除非用 INPUT 直接给合法 JSON + skipLlm)。TTS key(除非 NO_TTS)。
+
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { createRequire } from "node:module";
+import { resolve, join } from "node:path";
+
+const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
+process.chdir(root);
+
+// --- .env (填充未设置的 key) ---
+const envPath = join(root, ".env");
+if (existsSync(envPath)) {
+  for (const line of (await readFile(envPath, "utf8")).split("\n")) {
+    const t = line.trim();
+    if (!t || t.startsWith("#")) continue;
+    const eq = t.indexOf("=");
+    if (eq < 0) continue;
+    const k = t.slice(0, eq).trim();
+    if (k && process.env[k] === undefined) process.env[k] = t.slice(eq + 1).trim();
+  }
+}
+
+// --- config/default.yaml (yaml 通过 createRequire 从 shared 的依赖上下文解析出确切 dist 路径) ---
+const sharedRequire = createRequire(join(root, "packages/shared/package.json"));
+const yamlPath = sharedRequire.resolve("yaml");
+const yamlMod = await import(pathToFileURL(yamlPath).href);
+const config = yamlMod.parse(await readFile(join(root, "config/default.yaml"), "utf8"));
+
+// --- 模块(从编译好的 dist 导入) ---
+const { generateDocument } = await import(join(root, "packages/text/dist/index.js"));
+const { generateAudio } = await import(join(root, "packages/audio/dist/index.js"));
+const { resolveOutputDir } = await import(join(root, "packages/shared/dist/node.js"));
+
+const template = process.env.TEMPLATE ?? "github-trending";
+const source = process.env.INPUT ? undefined : (process.env.SOURCE ?? "github-trending");
+const providerName = process.env.TTS_PROVIDER ?? config.tts?.provider ?? "openai-tts";
+const providerConfig = config.tts?.[providerName];
+const outputDir = resolveOutputDir(config.output?.dir);
+const workDir = join(outputDir, "tmp", "dump");
+await mkdir(workDir, { recursive: true });
+
+const text = process.env.INPUT
+  ? await readFile(resolve(process.env.INPUT), "utf8")
+  : undefined;
+
+console.log(`\n=== 文字模块 (template=${template}, source=${source ?? "(input file)"}) ===`);
+const { doc: textDoc, publish } = await generateDocument({
+  template,
+  text,
+  source,
+  sourceArgs: undefined,
+  collectorConfig: source ? config.collect?.[source] : undefined,
+  llm: { model: process.env.LLM_MODEL ?? config.llm?.model ?? "glm-5.1" },
+  skipLlm: process.env.SKIP_LLM === "1",
+});
+let doc = textDoc;
+await writeFile(join(workDir, "doc-after-text.json"), JSON.stringify(doc, null, 2));
+console.log(`  ✓ 产出了 ${doc.data.length} 个段(cover=${doc.data.find(s => s.kind === "cover") ? "有" : "无"})。已写到 ${join(workDir, "doc-after-text.json")}`);
+if (publish) console.log(`  publish (独立产物,不在 VideoDocument 内): title="${publish.title}"  tags=[${(publish.tags ?? []).join(",")}]`);
+
+console.log(`\n=== 音频模块 (provider=${providerName}${process.env.NO_TTS === "1" ? ", 静音" : ""}) ===`);
+doc = await generateAudio(doc, {
+  workDir,
+  provider: providerName,
+  voiceId: providerConfig?.defaultVoice,
+  model: providerConfig?.model,
+  format: config.tts?.format,
+  speed: config.tts?.speed,
+  skip: process.env.NO_TTS === "1",
+  alignment: config.tts?.alignment,
+});
+await writeFile(join(workDir, "doc-after-audio.json"), JSON.stringify(doc, null, 2));
+
+// --- 人读摘要 ---
+const totalDur = doc.data.reduce((a, s) => a + (s.duration ?? 0), 0);
+console.log(`\n================ VideoDocument 摘要 ================`);
+console.log(`template=${doc.template}  段数=${doc.data.length}  总时长≈${totalDur.toFixed(1)}s`);
+console.log(`meta: title="${doc.meta?.title ?? ""}"  subtitle="${doc.meta?.subtitle ?? ""}"  trendSummary="${doc.meta?.trendSummary ?? ""}"`);
+if (publish) console.log(`publish (独立): title="${publish.title}"  tags=[${(publish.tags ?? []).join(",")}]`);
+console.log(`----------------------------------------------------`);
+for (const s of doc.data) {
+  const cards = s.cardList?.length ?? 0;
+  const caps = s.caption?.length ?? 0;
+  const origin = (s.captionOrigin ?? "").replace(/\s+/g, " ").slice(0, 48);
+  console.log(`  [${s.id}] kind=${s.kind}  dur=${(s.duration ?? 0).toFixed(1)}s  cards=${cards}  captions=${caps}`);
+  if (origin) console.log(`          口播: ${origin}${(s.captionOrigin ?? "").length > 48 ? "…" : ""}`);
+  if (s.extension?.type === "github-trending") {
+    const g = s.extension;
+    console.log(`          repo: ${g.repo.fullName}  ★${g.repo.stars ?? "?"}  今日+${g.repo.todayStars ?? "?"}  (extension)`);
+  }
+}
+console.log(`====================================================`);
+console.log(`完整 JSON: ${join(workDir, "doc-after-audio.json")}`);
+console.log(`\n下一步: 确认口播/卡片/字幕无误后,用 ./scripts/verify-refactor.sh 跑完整渲染。`);

+ 84 - 0
scripts/verify-refactor.sh

@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# 验证重构后的端到端流水线(CLI → runDocument → text/audio/renderer → 导出 → OSS → 飞书)。
+#
+# 用法(在仓库根目录):
+#   ./scripts/verify-refactor.sh                  # 默认:github-trending / bilibili / source 采集
+#   TEMPLATE=github-trending PLATFORM=bilibili SOURCE=github-trending ./scripts/verify-refactor.sh
+#   INPUT=test/fixtures/sample-knowledge.json TEMPLATE=knowledge ./scripts/verify-refactor.sh  # 用本地输入文件
+#   NO_TTS=1 ./scripts/verify-refactor.sh         # 跳过 TTS(静音视频,用于只验布局)
+#   NO_PUBLISH=1 ./scripts/verify-refactor.sh     # 跳过 OSS/飞书
+#   SKIP_BUILD=1 ./scripts/verify-refactor.sh     # 跳过 pnpm build(dist 已是最新时)
+#
+# 需要的真机依赖:.env 里的 LLM/TTS key、Remotion Chrome、(可选)OSS/飞书凭据。
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+TEMPLATE="${TEMPLATE:-github-trending}"
+PLATFORM="${PLATFORM:-bilibili}"
+SOURCE="${SOURCE:-github-trending}"
+
+echo "================ 验证重构流水线 ================"
+echo "模板: $TEMPLATE   平台: $PLATFORM"
+[ -n "${INPUT:-}" ] && echo "输入文件: $INPUT" || echo "数据源: $SOURCE"
+[ "${NO_TTS:-0}" = "1" ] && echo "TTS: 跳过 (--no-tts)"
+[ "${NO_PUBLISH:-0}" = "1" ] && echo "发布: 跳过 (--no-publish)"
+echo "================================================"
+
+if [ "${SKIP_BUILD:-0}" != "1" ]; then
+  echo ">> 构建所有包 (turbo build)..."
+  pnpm build
+fi
+
+CLI="apps/cli/dist/index.js"
+if [ ! -f "$CLI" ]; then
+  echo "!! CLI 未构建($CLI 不存在)。请去掉 SKIP_BUILD 或先 pnpm build。" >&2
+  exit 1
+fi
+
+ARGS=(render -t "$TEMPLATE" -p "$PLATFORM")
+if [ -n "${INPUT:-}" ]; then
+  ARGS+=("$INPUT")
+else
+  ARGS+=("--source" "$SOURCE")
+fi
+[ "${NO_TTS:-0}" = "1" ] && ARGS+=("--no-tts")
+[ "${NO_PUBLISH:-0}" = "1" ] && ARGS+=("--no-publish")
+
+echo ">> 运行: node $CLI ${ARGS[*]}"
+echo "------------------------------------------------"
+set +e
+node "$CLI" "${ARGS[@]}" 2>&1 | tee /tmp/verify-refactor.log
+CODE=${PIPESTATUS[0]}
+set -e
+echo "------------------------------------------------"
+
+if [ "$CODE" -ne 0 ]; then
+  echo "!! 流水线失败(exit $CODE)。完整日志见 /tmp/verify-refactor.log" >&2
+  exit "$CODE"
+fi
+
+echo ">> 解析产物..."
+MP4S=$(grep -oE '\-> (.+\.mp4)' /tmp/verify-refactor.log | sed 's/^-> //' || true)
+OSSURLS=$(grep -oE 'oss: (\S+)' /tmp/verify-refactor.log | sed 's/^oss: //' || true)
+
+echo "================ 结果 ================"
+if [ -z "$MP4S" ]; then
+  echo "⚠️  未在输出中解析到 MP4(检查上方日志)"
+else
+  for mp4 in $MP4S; do
+    if [ -f "$mp4" ]; then
+      SIZE=$(du -h "$mp4" | cut -f1)
+      echo "  ✅ $mp4 ($SIZE)"
+    else
+      echo "  ⚠️  $mp4 (CLI 报告但文件不存在)"
+    fi
+  done
+fi
+if [ -n "$OSSURLS" ]; then
+  echo "  OSS:"
+  echo "$OSSURLS" | sed 's/^/    - /'
+fi
+echo "======================================"
+echo "完成。如需检视 VideoDocument 契约,运行:node scripts/dump-video-document.mjs"

+ 1 - 1
turbo.json

@@ -3,7 +3,7 @@
   "tasks": {
   "tasks": {
     "build": {
     "build": {
       "dependsOn": ["^build"],
       "dependsOn": ["^build"],
-      "outputs": ["dist/**"]
+      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
     },
     },
     "dev": {
     "dev": {
       "cache": false,
       "cache": false,