# CLAUDE.md 本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。 ## 项目概述 文本转视频生成流水线。核心是一条**解耦的三模块管线**:AI 文字 → AI 音频 → Remotion 调度器,三者只通过**一个固定 JSON 类型 `VideoDocument`** 通信(每个模块用 Zod 校验输入、不满足即报错)。核心功能以**服务方式常驻运行**(Next.js 服务进程内串联三模块、原生定时执行),CLI 与 WebUI 是它的薄客户端。 > 历史背景:早期是一条 `runPipeline` 串 7 阶段、Next.js 通过 `spawn` CLI 子进程跑渲染。本次重构把它拆成三模块 + 单一 JSON 契约,并把核心逻辑从 CLI 进程内化到服务进程内(不再 spawn)。 ## 常用命令 ```bash pnpm build # 构建所有包 (turbo) pnpm typecheck # 类型检查所有包 pnpm lint # Lint 所有包 # 渲染(CLI 现在进程内直跑核心 runDocument) node apps/cli/dist/index.js render -t github-trending -p bilibili --source github-trending # Web 服务(核心服务宿主)开发 cd apps/web && pnpm dev ``` > 部署(Docker Compose / HTTP API / 配置 / OSS·飞书发布)详见 [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md),环境变量模板见 [`.env.example`](.env.example)。提交代码遵循 [`docs/COMMIT_CONVENTION.md`](docs/COMMIT_CONVENTION.md)(Angular 规范:type(scope): subject ≤50 字、所有行 ≤72 字符、body 写动机与新旧行为对比、不兼容变动标 BREAKING CHANGE)。 ## 架构 pnpm workspaces + Turborepo 的 monorepo。 ``` apps/cli/ CLI(薄客户端:进程内调用 core 的 runDocument) apps/web/ Next.js 15 = 核心服务宿主(HTTP API + 调度器 + 任务存储 + health),进程内调 core packages/shared/ Zod schema、类型、常量、LLM client、VideoDocument 契约、工具、covered 状态存储(node 出口) packages/collect/ 数据源(DataSource 注册表 + 6 个 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/ 7 个模板的 Remotion 组件(读 RenderProps/RenderScene) packages/core/ 瘦编排器 runDocument:链 text→audio→renderer + 渲染成功后写 covered 状态 + 飞书通知 + 清理 ``` ### 三模块数据流(核心) 每个模块都是**纯函数 over JSON、无状态**(并发友好)。模块间只传 `VideoDocument`: ``` 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 清理 ``` 核心类型链:`VideoDocument`(模块间契约)→ `RenderProps`/`RenderScene`(renderer↔template 契约)→ Remotion props。 ### VideoDocument —— 单一 JSON 契约(v2.0) 定义在 `packages/shared/src/types/document.ts`。三模块都用它做输入校验: ``` 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 变体即可,不污染通用契约。 **旧 → 新字段映射**:`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`)。 ## 模块职责 ### 1. 文字模块 `packages/text` `generateDocument(input) → VideoDocument`,三层分离(**数据源与 AI 文字逻辑拆开**,为混合数据源留口子): - **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`→中文口语)。 > 文字模块的"多步"实现为 collect→generate→assemble 三层;其中"逐仓描述→全局首屏→章节口播→发布元信息"目前由**一次 LLM 调用**产出(质量优先、必要时合并)。`github-trending` 采集器现**逐仓抓取 README**(按 todayStars 取 top8)做描述扎实度。 > 旧 ``/`` 注释协议仍由采集器输出(让既有 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**,不改入参。 ### 3. 渲染模块 `packages/renderer` `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`(失败只告警)。 平台多路复用在本模块(Document 平台无关、音频时长用秒;画面帧由模板在渲染期算)。 ### 编排器 `packages/core` `runDocument(input, config) → DocumentRunResult`:链 text→audio→renderer,飞书通知(成功推送 OSS 链接/失败推送错误),TTL 清理。**无状态**,服务/CLI 都直接进程内调它。 ## 核心服务化(重要) **核心逻辑在 Next.js 服务进程内运行,不再 spawn CLI**: - `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)。判别函数返回的 external 类型**必须是 `module`**(让 webpack 对 `await import()` emit 原生 `import()`),**绝不能用 `commonjs`**——这些包是 ESM-only(`"type":"module"`、`exports` 只有 `import` 条件),`commonjs` 会在运行期把 `import()` 改写成 `require()`,CJS 解析器找不到匹配条件 → `ERR_PACKAGE_PATH_NOT_EXPORTED`("No exports main defined")。`@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 约束 Remotion 通过其 webpack dev server 提供静态资源。**组件中绝不能用文件系统绝对路径** — 必须用 `staticFile(filename)`。渲染模块把所有资源(音频、图片、背景图、字体)复制进统一 `publicDir`,用文件名传 props。 音频按场景独立播放:每个 `` 含自己的 `