本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。
文本转视频生成流水线。核心是一条解耦的三模块管线:AI 文字 → AI 音频 → Remotion 调度器,三者只通过一个固定 JSON 类型 VideoDocument 通信(每个模块用 Zod 校验输入、不满足即报错)。核心功能以服务方式常驻运行(Next.js 服务进程内串联三模块、原生定时执行),CLI 与 WebUI 是它的薄客户端。
历史背景:早期是一条
runPipeline串 7 阶段、Next.js 通过spawnCLI 子进程跑渲染。本次重构把它拆成三模块 + 单一 JSON 契约,并把核心逻辑从 CLI 进程内化到服务进程内(不再 spawn)。
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,环境变量模板见.env.example。提交代码遵循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。
定义在 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)。
packages/textgenerateDocument(input) → VideoDocument,三层分离(数据源与 AI 文字逻辑拆开,为混合数据源留口子):
@pipeline/collect 取数,返回 markdown(喂 LLM)+ 类型化 repos: RepoMeta[](权威元数据通道)。detectInputFormat → 已是合法 JSON 则直接用;否则调 LLM(复用 getParsePrompt,截断时重试一次)→ 校验 → 清洗。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)做描述扎实度。 旧<!-- repo-meta -->/<!-- repo-images -->注释协议仍由采集器输出(让既有 prompt 不变),但已被类型化repos覆盖层取代为非关键冗余,可在后续清理中移除。
packages/audiogenerateAudio(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,不改入参。
packages/rendererrenderDocument(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/corerunDocument(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 与调度器都走它。@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)。
@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.json 的 build.outputs 必须含 .next/**(排除 .next/cache/**),否则 turbo 缓存命中时会跳过 next build、不产出 .next。.dockerignore 排除 .turbo,避免本地 turbo 缓存泄入镜像构建。apps/web/src/lib/scheduler.ts + instrumentation.ts)沿用 node-cron,到点调 startRenderJob。apps/cli/src/commands/render.ts)也进程内调 runDocument(薄客户端,本地无需服务在跑)。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 即此类型的再导出(迁移期别名)。
Provider 通过 registerProvider() 在 packages/tts/src/providers/ 注册,实现 TTSProvider 接口(synthesize/listVoices/align)。音频模块用 getProvider(name) 取用。whisper 对齐(alignWithWhisper)可选(默认走 provider 的 estimateWordTimestamps)。
7 个模板(news、knowledge、opinion、marketing、github-trending、github-weekly、github-daily-pick、github-weekly-recap),每个在 packages/templates/src/<name>/index.tsx。Root.tsx 路由:scene.kind === "cover" → 内置 CoverScene;"outro" → 内置 OutroScene;其他 → SCENE_MAP[template]。
github-trending / github-daily-pick cover:背景图(default.png 兜底)+深色蒙版,居中深色标题卡,仅展示刊头标题 + 日期 + 主要语言标签(由 content 场景的 repo.language 聚合去重)+ trendSummary 概括,不再展示仓库列表;进度条上方 ChapterToc(高亮当前章节)。github-weekly / github-weekly-recap cover:浅色杂志风刊头;github-weekly-recap 复用 github-weekly 的场景组件(仅 tag 从"本周 +N"换成 extension.coveredDate 的"推荐 · M月D日")。github-daily-pick 内容场景:一个项目的多切面章节(scene.title = 切面标题,右上角章节进度徽标,卡片为切面内容),与 board 模板的"每仓一场景"不同。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。新增场景字段时:VideoSegment(document.ts)+ RenderScene(render.ts)+ 模板组件三处同步,并在 renderer 的 composeRenderProps 显式透传(不是 ...seg 展开)。
github-daily-pick 每天推荐一个未讲过的开源项目(六幕叙事:钩子/痛点/亮相/亮点/上手/总结;选题=日/周/月三榜并集按综合热度排序,非只看当日热榜);github-weekly-recap 每周日回顾本周推荐。去重历史是全管线唯一的跨运行状态:
packages/shared/src/state/covered-store.ts(仅从 @pipeline/shared/node 出口——根 barrel 是 client-safe,绝不能 re-export)。JSON 文件 {PIPELINE_COVERED_DIR}/covered.json,原子写(tmp+rename),同日同仓 upsert,写入时顺带 prune(窗口+7 天)。目录:PIPELINE_COVERED_DIR env > 系统 tmp;部署时必须指向持久卷(compose 指 jobs bind mount 的 covered/ 子目录,k8s 指 jobs PVC 子目录)。绝不能放 outputDir(30 天 TTL 会删)。collect/collectors/github-daily-pick.ts):同日已有成功记录 → 幂等选回同一项目;否则日榜全量筛掉窗口内已讲(isCovered,窗口默认 90 天)取热度第一;日榜耗尽回退周榜;双榜全灭 → 显式 throw(飞书告警,不自动放宽)。runDocument 在渲染成功后(recordPipelineState)从最终 doc 的 extension 提取 repo + LLM 文案 + outputs 写入——选题/LLM/渲染失败都不浪费选题。best-effort:写失败记 result.stateError 不 fail 整个 run。周报只写 recaps(防同周重发),不进 dedup。github-weekly-recap collector 纯读 getWeekRecords(weekStart)(startOfWeekIso,TIMEZONE 口径)拼 markdown(含当日亮点/介绍/点评),repo 快照带 coveredDate 流经 RepoMeta → extension.coveredDate → 模板 tag。文案由 LLM 以回顾视角重写,不逐字复述。config/default.yaml 的 state.coveredWindowDays(须与 collect.github-daily-pick.windowDays 一致)。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,永不抛错。
packages/renderer/src/oss.ts,ali-oss 分片上传),ossUrl 回填到 ExportFile。packages/core/src/publish/:notifyFeishuSuccess/notifyGenerationFailure,可选 HMAC 签名 + @ 成员)。渲染成功→推 OSS 链接;上传失败→推"已生成但上传失败";渲染本身失败→推"生成失败"。resolvePublishConfig() 把 config/default.yaml 的 oss/feishu 与环境变量合并。--no-publish/skipPublish 跳过两者。.env(monorepo 根)— 所有 provider 的 API Key(CLI 启动时加载,Next.js 经 next.config.mjs 加载)。config/default.yaml — TTS provider、模型、模板配色、输出设置、OSS/飞书非敏感配置、collect 数据源 URL(github-trending 现含 repoUrl 用于逐仓 README)、schedules。--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_* 等。
容器内置进程内调度器: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。
LLMClient(packages/shared/src/llm/client.ts)max_tokens:16384。大 trending 列表可能撑爆被截断 → JSON 不全;文字模块对解析失败重试一次(追加精简指令),错误带 finish_reason。assemble.ts 按模板拼装(不交给 LLM,不含日期/数量);trendSummary 由 LLM 产出(既朗读又作封面副标题)。内容场景口播只念项目短名。所有口播经 normalizeCountsForTTS 把 Nk 转中文口语。--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 仍在构建期预置。容器实际构建需在真实环境验证(本仓库无法离线构建镜像)。