瀏覽代碼

修改输出目录结构,增加OSS适配器,增加缓存自动清理,增加飞书webhook适配器,修复容器中的时区问题

lkatzey 1 周之前
父節點
當前提交
1d627c86cb

+ 39 - 1
.env.example

@@ -1,14 +1,20 @@
 # Environment variables — secrets and endpoint URLs only.
 # Copy this file to .env and fill in values.
 # Model, voice, and other business settings go in config/default.yaml or CLI flags.
+#
+# In Docker, docker-compose.yml reads this file and substitutes the values below
+# into the container's environment — you do NOT need a .env inside the image.
 
 # --- LLM ---
 OPENAI_API_KEY=
 OPENAI_BASE_URL=
 
-# --- TTS: OpenAI-compatible ---
+# --- TTS: OpenAI-compatible (default provider) ---
 OPENAI_TTS_API_KEY=
 OPENAI_TTS_BASE_URL=
+# OPENAI_TTS_MODEL=seed-tts-1.1
+# TTS retry count on transient failures (network error / 429 / 5xx). Default 3; 0 disables.
+# TTS_MAX_RETRIES=3
 
 # --- TTS: Fish Audio ---
 FISH_AUDIO_API_KEY=
@@ -19,3 +25,35 @@ MINIMAX_GROUP_ID=
 
 # --- TTS: ElevenLabs ---
 ELEVENLABS_API_KEY=
+
+# --- Output ---
+# Unified output directory for CLI / HTTP / WebUI.
+# Leave empty to use config/default.yaml `output.dir` (default ./output, i.e. repo root /output).
+# In Docker this is forced to /app/output by docker-compose.
+# OUTPUT_DIR=/app/output
+# Auto-clean cached outputs older than N days (0 disables). Default 30.
+# OUTPUT_RETENTION_DAYS=30
+# Timezone for the github-trending cover date (首屏 + 开场口播) and the output
+# date subfolder, so the two never disagree and never roll over a day early in
+# a UTC container. Default Asia/Shanghai. Docker also sets TZ to this value.
+# TIMEZONE=Asia/Shanghai
+
+# --- Publish: Alibaba Cloud OSS ---
+# When region/bucket + keys are all present, rendered videos are uploaded and a
+# resource URL is returned. Leave blank to disable OSS upload.
+OSS_REGION=oss-cn-hangzhou
+OSS_BUCKET=
+OSS_ACCESS_KEY_ID=
+OSS_ACCESS_KEY_SECRET=
+# OSS_ENDPOINT=
+# OSS_PATH=videos/
+# OSS_PUBLIC_DOMAIN=
+# OSS_SECURE=true
+
+# --- Publish: Feishu (Lark) custom-bot webhook ---
+# When set, results / failures are pushed after each job.
+FEISHU_WEBHOOK_URL=
+# FEISHU_WEBHOOK_SECRET=
+
+# --- Service port (host port mapped to container 3000) ---
+PORT=13000

+ 30 - 2
CLAUDE.md

@@ -20,6 +20,8 @@ node apps/cli/dist/index.js render test/fixtures/sample-knowledge.json -t knowle
 cd apps/web && pnpm dev
 ```
 
+> 部署(Docker Compose / HTTP API / 配置 / OSS·飞书发布)详见 [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md),环境变量模板见 [`.env.example`](.env.example)。
+
 ## 架构
 
 pnpm workspaces + Turborepo 的 monorepo 结构。
@@ -42,7 +44,8 @@ packages/templates/ 5 个模板的 Remotion React 组件
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
 5. **render** — 打包 Remotion bundle,将资源复制到 publicDir,通过 `renderMedia` 渲染
-6. **export** — 输出最终 MP4 到指定目录
+6. **export** — 输出最终 MP4 到**统一输出目录**,按 `{模板名称}/{ISO日期(YYYY-MM-DD)}/{原文件名}.mp4` 自动建子目录
+7. **publish**(可选,按需开启)— 渲染成功后上传到阿里云 OSS 并推送飞书 webhook:上传成功推送 OSS 资源链接,生成/上传失败推送失败信息。生成失败(未产出文件)也会推送失败信息。未配置 OSS/飞书时跳过
 
 核心类型链:`VideoInput` → `ParsedContent` → `ComposedProject` → Remotion props。
 
@@ -67,12 +70,37 @@ Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册
 
 `SubtitleBar`(`packages/templates/src/base/components/subtitle-bar.tsx`)接受可选 `fontSize` 和 `style` prop——竖屏场景可传 `{ bottom: 380 }` 提高字幕位置、`fontSize: 56` 放大字号;横屏不传则保持默认(`bottom: 60`、`fontSize: 40`)。
 
+## 输出目录与发布
+
+### 统一输出目录
+
+CLI / HTTP / WebUI 的视频都写入**同一个**输出目录,由 `packages/shared` 的 `resolveOutputDir()` 统一解析(相对路径相对 monorepo 根,CLI 与 `apps/web` 因此落地到同一物理目录)。解析优先级:`OUTPUT_DIR` 环境变量 > `config.output.dir` > `./output`。
+
+目录布局:`{outputDir}/{模板}/{ISO日期 YYYY-MM-DD}/{模板}-{平台}-{jobId前8位}.mp4`,在 `export.ts` 中由 `isoDateString()` 生成日期子目录。日期取**配置时区**(默认 `Asia/Shanghai`,可用 `TIMEZONE` 覆盖)而非 UTC,与首屏显示日期保持一致,避免 UTC 容器跨天错位。
+
+### 缓存自动清理
+
+每次 `runPipeline` 启动时按 `config.output.retentionDays`(默认 30,可用 `OUTPUT_RETENTION_DAYS` 覆盖,设 0 禁用)做一次机会性扫描,删除 mtime 超过 TTL 的文件及随之变空的目录(`packages/core/src/cleanup.ts`)。该清理是 best-effort,永不抛错、不会中断渲染。
+
+### 发布(OSS + 飞书)
+
+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`)。
+
+> publish 仅在 CLI 进程(`runPipeline`)中执行;WebUI 通过 spawn CLI 复用该流程,不直接依赖 `@pipeline/core`/`ali-oss`,故 `ali-oss` 只出现在 core 的依赖里。
+
 ## 配置
 
 - `.env`(monorepo 根目录)— 所有 provider 的 API Key(CLI 启动时加载,Next.js 通过 `next.config.ts` 加载)
-- `config/default.yaml` — TTS provider、模型、模板配色、输出设置
+- `config/default.yaml` — TTS provider、模型、模板配色、输出设置、OSS/飞书非敏感配置
 - CLI 按以下顺序读取配置:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`
 
+### 发布相关环境变量
+
+- `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
+
 ## 核心 Schema
 
 全部在 `packages/shared/src/types/` 中用 Zod 定义:

+ 28 - 7
apps/cli/src/commands/render.ts

@@ -1,6 +1,7 @@
 import { Command } from "commander";
-import { runPipeline, type PipelineConfig } from "@pipeline/core";
+import { runPipeline, resolvePublishConfig, type PipelineConfig } from "@pipeline/core";
 import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
+import { resolveOutputDir } from "@pipeline/shared/node";
 import { getCollector, listCollectorNames } from "@pipeline/collect";
 import { readFileSync, existsSync } from "node:fs";
 import { resolve, dirname } from "node:path";
@@ -8,13 +9,15 @@ import { parse as parseYaml } from "yaml";
 
 // Commander --no-tts sets opts.tts = false (not opts.noTts)
 const skipTts = (opts: any) => opts.tts === false;
+// Commander --no-publish sets opts.publish = false
+const skipPublish = (opts: any) => opts.publish === false;
 
 export const renderCommand = new Command("render")
   .description("Generate video from text, markdown, or structured JSON input")
   .argument("[input]", "Input file path (JSON, markdown, or plain text). Required unless --source is used.")
   .requiredOption("-t, --template <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
   .option("-p, --platform <preset>", "Platform preset", "bilibili")
-  .option("-o, --output <dir>", "Output directory", "./output")
+  .option("-o, --output <dir>", "Output directory (default: config output.dir or ./output)")
   .option("--assets-dir <dir>", "Image assets directory (defaults to JSON file location)")
   .option("--config <path>", "Config file path")
   .option("-v, --verbose", "Verbose logging")
@@ -38,6 +41,7 @@ export const renderCommand = new Command("render")
   .option("--tts-format <format>", "TTS audio format (mp3|wav|pcm)")
   .option("--no-tts", "Skip TTS (generate silent video)")
   .option("--channel-name <name>", "Channel name displayed in video watermark")
+  .option("--no-publish", "Skip OSS upload + Feishu notification")
 
   // Alignment
   .option("--alignment <mode>", "Timestamp alignment: whisper|native")
@@ -106,8 +110,17 @@ export const renderCommand = new Command("render")
     const assetsRoot = resolve(projectRoot, "assets");
 
     const noTts = skipTts(opts);
+    const noPublish = skipPublish(opts);
     const flags: string[] = [];
     if (noTts) flags.push("no-tts");
+    if (noPublish) flags.push("no-publish");
+
+    // Unified output directory (honors OUTPUT_DIR env > config > ./output).
+    const outputDir = resolveOutputDir(opts.output || config?.output?.dir);
+    const retentionDays = Number(
+      process.env.OUTPUT_RETENTION_DAYS ?? config?.output?.retentionDays ?? 30
+    );
+    const publish = noPublish ? undefined : resolvePublishConfig(config);
 
     const pipelineConfig: PipelineConfig = {
       branding: {
@@ -126,8 +139,11 @@ export const renderCommand = new Command("render")
         speed: opts.ttsSpeed || config?.tts?.speed || 1.0,
       },
       output: {
-        dir: resolve(opts.output || config?.output?.dir || "./output"),
+        dir: outputDir,
+        retentionDays,
       },
+      publish,
+      skipPublish: noPublish,
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },
       skipTts: noTts,
@@ -135,7 +151,7 @@ export const renderCommand = new Command("render")
       alignment: buildAlignmentConfig(opts, config),
     };
 
-    const stages = ["parse", "tts", "assets", "compose", "render", "export"];
+    const stages = ["parse", "tts", "assets", "compose", "render", "export", "publish"];
 
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
     console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
@@ -147,7 +163,7 @@ export const renderCommand = new Command("render")
         platforms: platforms as any,
         ttsProvider: providerName as any,
         voiceId: pipelineConfig.tts.voiceId,
-        outputPath: resolve(opts.output || "./output"),
+        outputPath: outputDir,
         source: opts.source,
       },
       pipelineConfig,
@@ -162,15 +178,16 @@ export const renderCommand = new Command("render")
             compose: "Composing scenes",
             render: "Rendering video",
             export: "Exporting",
+            publish: "Publishing (OSS + Feishu)",
           };
           const stepNum = stages.indexOf(name as string) + 1;
-          console.log(`  [${stepNum}/6] ${labels[name] || name}${platformLabel}...`);
+          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}/6] ${name}${platformLabel} done`);
+          console.log(`  [${stepNum}/${stages.length}] ${name}${platformLabel} done`);
         },
         onError: (stage, error) => {
           console.error(`\n  Error in ${stage}: ${error.message}`);
@@ -186,6 +203,10 @@ export const renderCommand = new Command("render")
       console.log(`\nCompleted! Job ID: ${job.id}`);
       for (const file of job.exported.files) {
         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 (job.publishError) {
+        console.error(`\nPublish warning: ${job.publishError}`);
       }
     } else {
       console.error(`\nFailed: ${job.error}`);

+ 5 - 4
apps/web/src/app/api/download/route.ts

@@ -1,18 +1,19 @@
 import { NextResponse } from "next/server";
 import { readFileSync, existsSync, statSync } from "node:fs";
 import { resolve } from "node:path";
+import { resolveOutputDir } from "@pipeline/shared/node";
 
 export async function GET(request: Request) {
   const { searchParams } = new URL(request.url);
-  const filePath = searchParams.get("file");
+  const filePath = resolve(searchParams.get("file") ?? "");
   const download = searchParams.get("download") === "1";
 
-  if (!filePath) {
+  if (!searchParams.get("file")) {
     return NextResponse.json({ error: "Missing file parameter" }, { status: 400 });
   }
 
-  // Security: only allow files under the output directory or job store
-  const outputDir = resolve(process.cwd(), "output");
+  // Security: only allow files under the unified output directory or job store
+  const outputDir = resolveOutputDir();
   if (!filePath.startsWith(outputDir) && !filePath.startsWith("/tmp/pipeline-jobs/")) {
     return NextResponse.json({ error: "Access denied" }, { status: 403 });
   }

+ 13 - 1
apps/web/src/app/api/render/route.ts

@@ -1,5 +1,7 @@
 import { NextResponse } from "next/server";
 import { createJob, updateJob } from "@/lib/job-store";
+import { loadConfig } from "@/lib/config";
+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";
@@ -35,12 +37,16 @@ export async function POST(request: Request) {
   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 outputDir = resolveOutputDir(config?.output?.dir);
+
   const cliArgs = [
     "render",
     "-t", input.template,
     "-p", platforms.join(","),
     "--tts-provider", input.ttsProvider,
-    "--output", resolve("output"),
+    "--output", outputDir,
   ];
 
   if (input.source) {
@@ -99,9 +105,15 @@ export async function POST(request: Request) {
         };
       });
 
+      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;
+
       updateJob(job.id, {
         status: "completed",
         outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
+        ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
+        publishError,
       });
     } else {
       updateJob(job.id, {

+ 30 - 0
apps/web/src/app/jobs/[id]/page.tsx

@@ -25,6 +25,8 @@ interface Job {
   updatedAt: number;
   error?: string;
   outputFiles?: OutputFile[];
+  ossUrls?: string[];
+  publishError?: string;
 }
 
 const STATUS_BADGE: Record<string, string> = {
@@ -133,6 +135,34 @@ export default function JobDetailPage({ params }: { params: Promise<{ id: string
         </div>
       )}
 
+      {/* Publish warning (render succeeded, OSS/Feishu publish failed) */}
+      {job.publishError && (
+        <div className="card" style={{ borderColor: "#f59e0b", marginBottom: 24 }}>
+          <div style={{ color: "#f59e0b", fontWeight: 600, marginBottom: 4 }}>Publish warning</div>
+          <pre style={{ fontSize: 13, whiteSpace: "pre-wrap", color: "#f59e0b" }}>{job.publishError}</pre>
+        </div>
+      )}
+
+      {/* OSS resource links */}
+      {job.status === "completed" && job.ossUrls && job.ossUrls.length > 0 && (
+        <div className="card" style={{ marginBottom: 24 }}>
+          <h3 style={{ marginBottom: 16 }}>OSS Links</h3>
+          <div style={{ display: "grid", gap: 8 }}>
+            {job.ossUrls.map((url, i) => (
+              <a
+                key={i}
+                href={url}
+                target="_blank"
+                rel="noreferrer"
+                style={{ fontSize: 13, wordBreak: "break-all" }}
+              >
+                {url}
+              </a>
+            ))}
+          </div>
+        </div>
+      )}
+
       {/* Output files */}
       {job.status === "completed" && job.outputFiles && job.outputFiles.length > 0 && (
         <div className="card" style={{ marginBottom: 24 }}>

+ 101 - 0
apps/web/src/app/settings/page.tsx

@@ -12,6 +12,13 @@ interface EnvVars {
   MINIMAX_API_KEY: string;
   MINIMAX_GROUP_ID: string;
   ELEVENLABS_API_KEY: string;
+  OSS_REGION: string;
+  OSS_BUCKET: string;
+  OSS_ACCESS_KEY_ID: string;
+  OSS_ACCESS_KEY_SECRET: string;
+  OSS_PUBLIC_DOMAIN: string;
+  FEISHU_WEBHOOK_URL: string;
+  FEISHU_WEBHOOK_SECRET: string;
 }
 
 const DEFAULT_ENV: EnvVars = {
@@ -24,6 +31,13 @@ const DEFAULT_ENV: EnvVars = {
   MINIMAX_API_KEY: "",
   MINIMAX_GROUP_ID: "",
   ELEVENLABS_API_KEY: "",
+  OSS_REGION: "oss-cn-hangzhou",
+  OSS_BUCKET: "",
+  OSS_ACCESS_KEY_ID: "",
+  OSS_ACCESS_KEY_SECRET: "",
+  OSS_PUBLIC_DOMAIN: "",
+  FEISHU_WEBHOOK_URL: "",
+  FEISHU_WEBHOOK_SECRET: "",
 };
 
 export default function SettingsPage() {
@@ -198,6 +212,93 @@ export default function SettingsPage() {
         </div>
       </div>
 
+      <div className="card" style={{ marginBottom: 24 }}>
+        <h3 style={{ marginBottom: 8 }}>Publish — OSS &amp; Feishu</h3>
+        <div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 20 }}>
+          Rendered videos are uploaded to OSS and results are pushed to a Feishu webhook. Also configurable in <code>config/default.yaml</code>.
+        </div>
+
+        <div style={{ marginBottom: 20, paddingBottom: 20, borderBottom: "1px solid var(--border)" }}>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#3b82f6" }}>
+            Alibaba Cloud OSS
+          </div>
+          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
+            <div className="form-group">
+              <label>Region</label>
+              <input
+                className="form-input"
+                value={keys.OSS_REGION}
+                onChange={(e) => setKeys({ ...keys, OSS_REGION: e.target.value })}
+                placeholder="oss-cn-hangzhou"
+              />
+            </div>
+            <div className="form-group">
+              <label>Bucket</label>
+              <input
+                className="form-input"
+                value={keys.OSS_BUCKET}
+                onChange={(e) => setKeys({ ...keys, OSS_BUCKET: e.target.value })}
+              />
+            </div>
+            <div className="form-group">
+              <label>Access Key ID</label>
+              <input
+                className="form-input"
+                type="password"
+                value={keys.OSS_ACCESS_KEY_ID}
+                onChange={(e) => setKeys({ ...keys, OSS_ACCESS_KEY_ID: e.target.value })}
+              />
+            </div>
+            <div className="form-group">
+              <label>Access Key Secret</label>
+              <input
+                className="form-input"
+                type="password"
+                value={keys.OSS_ACCESS_KEY_SECRET}
+                onChange={(e) => setKeys({ ...keys, OSS_ACCESS_KEY_SECRET: e.target.value })}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <label>Public Domain (optional)</label>
+            <input
+              className="form-input"
+              value={keys.OSS_PUBLIC_DOMAIN}
+              onChange={(e) => setKeys({ ...keys, OSS_PUBLIC_DOMAIN: e.target.value })}
+              placeholder="https://cdn.example.com"
+            />
+            <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}>
+              Base URL used to build resource links (e.g. a CDN). If empty, the bucket default domain is used.
+            </div>
+          </div>
+        </div>
+
+        <div>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#10b981" }}>
+            Feishu Webhook
+          </div>
+          <div className="form-group">
+            <label>Webhook URL</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.FEISHU_WEBHOOK_URL}
+              onChange={(e) => setKeys({ ...keys, FEISHU_WEBHOOK_URL: e.target.value })}
+              placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
+            />
+          </div>
+          <div className="form-group">
+            <label>Signing Secret (optional)</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.FEISHU_WEBHOOK_SECRET}
+              onChange={(e) => setKeys({ ...keys, FEISHU_WEBHOOK_SECRET: e.target.value })}
+            />
+          </div>
+        </div>
+      </div>
+
       <div className="card" style={{ marginBottom: 24 }}>
         <h3 style={{ marginBottom: 20 }}>Data Sources</h3>
         <div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>

+ 4 - 0
apps/web/src/lib/job-store.ts

@@ -21,6 +21,10 @@ export interface JobData {
     height: number;
     durationSeconds: number;
   }>;
+  /** OSS resource URLs returned by the publish stage (one per uploaded file). */
+  ossUrls?: string[];
+  /** Failure detail from the publish stage (OSS upload / Feishu notify). */
+  publishError?: string;
   createdAt: number;
   updatedAt: number;
 }

+ 21 - 0
config/default.yaml

@@ -48,6 +48,27 @@ render:
 output:
   dir: "./output"
   format: "mp4"
+  # Auto-clean cached outputs older than this many days (0 disables). Default 30.
+  # Override with the OUTPUT_RETENTION_DAYS env var.
+  retentionDays: 30
+
+# Alibaba Cloud OSS — rendered videos are uploaded here and a resource URL is
+# returned. Non-sensitive values may live in this file; secrets (access keys)
+# belong in .env. Every field can be overridden by an OSS_* env var.
+oss:
+  region: "oss-cn-hangzhou"          # OSS_REGION
+  bucket: ""                         # OSS_BUCKET
+  accessKeyId: ""                    # OSS_ACCESS_KEY_ID  (use .env)
+  accessKeySecret: ""                # OSS_ACCESS_KEY_SECRET (use .env)
+  # endpoint: ""                     # custom endpoint, e.g. https://oss-cn-hangzhou.aliyuncs.com — OSS_ENDPOINT
+  # path: "videos/"                  # object key prefix — OSS_PATH
+  # publicDomain: ""                 # base URL for resource links (e.g. a CDN) — OSS_PUBLIC_DOMAIN
+  # secure: true                     # use HTTPS — OSS_SECURE
+
+# Feishu (Lark) custom-bot webhook — push results / failures after each job.
+feishu:
+  webhook: ""                        # FEISHU_WEBHOOK_URL (use .env)
+  # secret: ""                       # signing secret — FEISHU_WEBHOOK_SECRET
 
 templates:
   news:

+ 18 - 1
docker-compose.yml

@@ -12,8 +12,25 @@ services:
       - MINIMAX_API_KEY=${MINIMAX_API_KEY}
       - MINIMAX_GROUP_ID=${MINIMAX_GROUP_ID}
       - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
+      # Unified output + cache retention
+      - OUTPUT_DIR=${OUTPUT_DIR:-/app/output}
+      - OUTPUT_RETENTION_DAYS=${OUTPUT_RETENTION_DAYS:-30}
+      # Container timezone — keeps logs, ffmpeg, and new Date() on Beijing time
+      # so the daily-trending cover date never rolls over a day early/late.
+      - TZ=${TZ:-Asia/Shanghai}
+      # OSS (Alibaba Cloud Object Storage) — publish stage
+      - OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID:-}
+      - OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET:-}
+      - OSS_REGION=${OSS_REGION:-}
+      - OSS_BUCKET=${OSS_BUCKET:-}
+      - OSS_ENDPOINT=${OSS_ENDPOINT:-}
+      - OSS_PATH=${OSS_PATH:-}
+      - OSS_PUBLIC_DOMAIN=${OSS_PUBLIC_DOMAIN:-}
+      # Feishu custom-bot webhook — publish stage
+      - FEISHU_WEBHOOK_URL=${FEISHU_WEBHOOK_URL:-}
+      - FEISHU_WEBHOOK_SECRET=${FEISHU_WEBHOOK_SECRET:-}
     volumes:
-      - ./output:/app/apps/web/output
+      - ./output:/app/output
       - pipeline-jobs:/tmp/pipeline-jobs
     restart: unless-stopped
 

+ 266 - 0
docs/DEPLOYMENT.md

@@ -0,0 +1,266 @@
+# 部署文档
+
+文本转视频生成流水线的部署与运行说明。项目以**容器化、服务化**为重心:Docker Compose 一键起服务,CLI / HTTP / WebUI 共享同一套配置与统一输出目录。
+
+---
+
+## 1. 架构概览
+
+```
+                         ┌──────────────────────────────┐
+   浏览器 (WebUI) ──────► │  Next.js Web 服务 (端口 3000)  │
+   HTTP /api/render ────► │  apps/web                     │
+                         └───────────────┬──────────────┘
+                                         │ spawn 子进程
+                                         ▼
+                         ┌──────────────────────────────┐
+                         │  CLI  apps/cli/dist/index.js   │
+                         │  runPipeline (7 阶段)          │
+                         │   parse → tts → assets →       │
+                         │   compose → render → export    │
+                         │   → publish(可选)              │
+                         └───────────────┬──────────────┘
+                                         │
+                ┌────────────────────────┼────────────────────────┐
+                ▼                        ▼                        ▼
+        统一输出目录                 阿里云 OSS              飞书 Webhook
+  {output}/{模板}/{日期}/{文件}      (上传视频)            (推送结果/失败)
+```
+
+- **Web 服务**(Next.js 15)提供 UI 与 HTTP API;渲染时 `spawn` 调用 CLI 子进程执行 `runPipeline`。
+- **CLI**(`apps/cli`)是流水线唯一入口;WebUI 与 HTTP 都复用它,因此 OSS 上传 / 飞书推送只在 CLI 进程内发生一次。
+- **统一输出目录**:CLI / HTTP / WebUI 写入同一目录,按 `{模板}/{ISO日期}/{文件名}.mp4` 自动建子目录。
+- **monorepo**:pnpm workspaces + Turborepo(`packages/shared|core|tts|templates|collect`,`apps/cli|web`)。
+
+---
+
+## 2. 前置要求
+
+**Docker 方式(推荐)**
+- Docker 20+ 与 Docker Compose v2。
+
+**本地开发方式**
+- Node.js 22、pnpm 10、ffmpeg。
+- 渲染依赖 headless Chrome:首次运行 Remotion 会自动下载(`@remotion/renderer`),或设置 `PUPPETEER_EXECUTABLE_PATH` 指向系统 Chrome。
+
+---
+
+## 3. 快速开始:Docker Compose(推荐)
+
+```bash
+# 1. 准备配置(密钥 / 端点)
+cp .env.example .env
+#   编辑 .env,至少填入 LLM、TTS 的 key;按需开启 OSS / 飞书
+
+# 2. 构建并启动
+docker compose up --build -d
+
+# 3. 访问
+#    WebUI:   http://localhost:13000
+#    (PORT 可在 .env 中覆盖,默认 13000 → 容器 3000)
+```
+
+`docker compose up` 会自动读取仓库根目录的 `.env`,把其中的变量替换进容器的 `environment:`(见 `docker-compose.yml`)。因此**密钥放在宿主机 `.env`,不需要把 `.env` 打进镜像**。
+
+停止 / 查看日志 / 重建:
+
+```bash
+docker compose logs -f pipeline     # 跟随日志
+docker compose down                 # 停止
+docker compose up --build -d        # 改完代码后重新构建
+```
+
+容器内:
+- Web 服务运行于 `/app/apps/web`,监听 `3000`。
+- 输出目录 `/app/output` 挂载到宿主机 `./output`(视频落盘可见、可清理)。
+- 任务元数据(`jobs.json`)保存在 docker volume `pipeline-jobs`(`/tmp/pipeline-jobs`)。
+
+---
+
+## 4. 配置说明
+
+配置分两层:**`.env`(密钥 / 端点)** + **`config/default.yaml`(业务参数)**。CLI 读取顺序:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`;密钥统一走环境变量。
+
+### 4.1 环境变量(`.env`)
+
+| 变量 | 必填 | 说明 |
+| --- | --- | --- |
+| `OPENAI_API_KEY` / `OPENAI_BASE_URL` | 是* | LLM(OpenAI 兼容端点:DeepSeek / Qwen / GLM 等)。`*`用结构化 JSON + `--skip-llm` 时可省 |
+| `OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL` | 是* | 默认 TTS provider。`*`用 `--no-tts` 时可省 |
+| `OPENAI_TTS_MODEL` | 否 | TTS 模型,默认 `seed-tts-1.1` |
+| `TTS_MAX_RETRIES` | 否 | TTS 请求失败重试次数(网络错误 / 429 / 5xx,指数退避),默认 3;`0` 关闭 |
+| `FISH_AUDIO_API_KEY` | 否 | Fish Audio provider |
+| `MINIMAX_API_KEY` / `MINIMAX_GROUP_ID` | 否 | MiniMax provider |
+| `ELEVENLABS_API_KEY` | 否 | ElevenLabs provider |
+| `OUTPUT_DIR` | 否 | 统一输出目录;空则用 `config.output.dir`(默认 `./output`)。Docker 内固定 `/app/output` |
+| `OUTPUT_RETENTION_DAYS` | 否 | 缓存保留天数,默认 30;`0` 关闭自动清理 |
+| `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` | 否 | 阿里云 OSS;四项齐全才启用上传 |
+| `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` | 否 | OSS 自定义端点 / key 前缀 / 资源链接域名(如 CDN) / 是否 HTTPS |
+| `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
+| `PORT` | 否 | 宿主机映射端口,默认 13000 |
+
+> 也可在 WebUI **Settings** 页面填写并保存到 `.env`(敏感值自动脱敏显示)。
+
+### 4.2 `config/default.yaml`
+
+TTS provider 选择、模型、对齐(whisper)、模板配色、采集源(`collect`)、OSS/飞书的**非敏感**默认值(region/bucket/path 等)在此配置。详见文件内注释。
+
+---
+
+## 5. 输出目录与缓存清理
+
+- **布局**:`{OUTPUT_DIR}/{模板名}/{YYYY-MM-DD(UTC)}/{模板}-{平台}-{jobId前8位}.mp4`
+  - 例:`output/github-trending/2026-07-16/github-trending-bilibili-701832d4.mp4`
+- **统一性**:相对路径相对 monorepo 根解析,CLI 与 `apps/web` 落到同一物理目录;容器内为 `/app/output`。
+- **自动清理**:每次渲染启动时按 `OUTPUT_RETENTION_DAYS`(默认 30)扫描,删除 mtime 超过阈值的文件及随之变空的日期目录(best-effort,不影响渲染)。设 `0` 关闭。
+
+---
+
+## 6. 发布:OSS 与飞书
+
+渲染完成后自动执行(可被 `--no-publish` / `config.skipPublish` 跳过):
+
+| 场景 | 行为 |
+| --- | --- |
+| 上传 OSS 成功 | 推送飞书:✅ 视频生成成功 + OSS 资源链接 |
+| 渲染成功但上传失败 | 推送飞书:⚠️ 已生成但上传失败 + 本地路径 + 错误(本地视频仍保留) |
+| 渲染本身失败 | 推送飞书:❌ 生成失败 + 错误信息 |
+
+- OSS 用 `ali-oss` 分片上传;资源链接优先用 `OSS_PUBLIC_DOMAIN`(CDN),否则用 bucket 默认域名。**假定对象为公开读**;若为私读需另行接签名 URL。
+- 飞书支持自定义机器人的**签名校验**(`FEISHU_WEBHOOK_SECRET`),算法为 `base64(HMAC-SHA256(key=timestamp+"\n"+secret, message=""))`。
+- 上传后的 OSS URL 会回填到导出文件(CLI 打印 `oss: <url>`;WebUI 任务详情页展示「OSS Links」)。
+
+---
+
+## 7. 本地开发(非 Docker)
+
+```bash
+pnpm install
+pnpm build            # 构建所有包(含 apps/cli/dist、apps/web/.next)
+
+# CLI 直接渲染
+node apps/cli/dist/index.js render test/fixtures/sample-knowledge.json \
+  -t knowledge -p bilibili --skip-llm --no-tts --no-publish
+
+# WebUI 开发服务器(热更新)
+cd apps/web && pnpm dev    # http://localhost:3000
+```
+
+常用命令:
+
+```bash
+pnpm typecheck       # 全包类型检查
+pnpm lint            # 全包 lint
+pnpm build           # 全量构建
+```
+
+---
+
+## 8. CLI 用法
+
+```bash
+node apps/cli/dist/index.js render <input> -t <template> [options]
+```
+
+- `<input>`:JSON / Markdown / 纯文本文件路径;或用 `--source <name>` 从采集源取数(如 `github-trending`、`github-daily`)。
+- `-t/--template`:`news|knowledge|opinion|marketing|github-trending`(必填)。
+- `-p/--platform`:`bilibili|douyin-long|douyin-short|universal-16x9|universal-9x16`,逗号分隔多平台。
+- `-o/--output`:输出目录(默认走 `config.output.dir` / `./output`)。
+- `--no-tts`:跳过 TTS,生成静音视频(调试布局)。
+- `--skip-llm`:跳过 LLM,要求输入是合法 `VideoInputSchema` JSON。
+- `--no-publish`:跳过 OSS 上传与飞书推送。
+- `--config`:指定配置文件路径。
+
+其它子命令:`collect`、`preview`、`templates`、`voices`、`init`(`--help` 查看)。
+
+---
+
+## 9. HTTP API(服务化调用)
+
+### 发起渲染
+
+```http
+POST /api/render
+Content-Type: application/json
+
+{
+  "input": {
+    "template": "knowledge",
+    "platforms": ["bilibili"],
+    "ttsProvider": "openai-tts",
+    "text": "<VideoInput JSON / Markdown / 纯文本>"
+  },
+  "options": { "noTts": false }
+}
+```
+
+- `input.template`(必填)、`input.ttsProvider`(必填)。
+- `input.text` 与 `input.source` 二选一;`platforms` 缺省为 `["bilibili"]`。
+- 采集源:`input.source` + `input.sourceArgs`(如 `{ "owner": "x", "repo": "y" }`)。
+
+响应:`{ "jobId": "<uuid>", "status": "started" }`。
+
+### 轮询任务 / 下载
+
+```http
+GET /api/jobs/<jobId>            # 返回任务状态、outputFiles、ossUrls、publishError
+GET /api/jobs                    # 任务列表
+GET /api/download?file=<绝对路径>&download=1   # 下载视频(仅允许统一输出目录或任务目录内文件)
+```
+
+任务状态:`pending | running | completed | failed`。`completed` 后 `outputFiles` 给出本地路径,`ossUrls` 给出 OSS 链接(若已上传)。
+
+### cURL 示例
+
+```bash
+curl -X POST http://localhost:13000/api/render \
+  -H 'Content-Type: application/json' \
+  -d '{"input":{"template":"knowledge","platforms":["bilibili"],"ttsProvider":"openai-tts","text":"<JSON 字符串>"}}'
+
+# 轮询
+curl http://localhost:13000/api/jobs/<jobId>
+```
+
+---
+
+## 10. 数据持久化(卷)
+
+| 挂载 | 容器路径 | 用途 |
+| --- | --- | --- |
+| `./output` | `/app/output` | 视频产物 + 渲染临时目录(按模板/日期归档,自动清理) |
+| docker volume `pipeline-jobs` | `/tmp/pipeline-jobs` | 任务元数据 `jobs.json`(重启保留) |
+
+> 注意:任务元数据存在内存卷中,**删除 volume 会丢失任务历史**(但不影响已生成的视频文件)。
+
+---
+
+## 11. 排错
+
+| 现象 | 排查 |
+| --- | --- |
+| 渲染卡在 `[5/7] Rendering` | headless Chrome 未就绪。本地确认 Chrome / Chromium 可用,或设 `PUPPETEER_EXECUTABLE_PATH`;Docker 镜像已内置所需系统库 |
+| TTS 报 API_KEY 错误 | 检查 `.env` 的 TTS key;或临时用 `--no-tts` 调布局 |
+| TTS 报 502 / 503 / 网络错误 | TTS 网关上游瞬时故障(如 DNS、滚动重启);已内置重试(`TTS_MAX_RETRIES` 默认 3)。若持续失败,检查 `OPENAI_TTS_BASE_URL` 网关健康度或临时换 provider |
+| 日志里出现 `Note: … @remotion/media-parser … license` | 这是 Remotion 的**许可证提醒**(`parseMedia()` 探测音频时长时打印),不是错误、不影响渲染。若要消除,需在 `packages/tts/src/duration.ts` 的 `parseMedia()` 传入 `acknowledgeRemotionLicense: true`——注意这是对许可证的**法律确认**,公司用途请先确认是否需要 [Remotion 许可](https://remotion.dev/license) |
+| OSS 上传失败 | 检查 `OSS_REGION/BUCKET/ACCESS_KEY_*`;任务详情会展示 `Publish warning`,本地视频仍生成 |
+| 飞书收不到消息 | 确认 `FEISHU_WEBHOOK_URL`;若启用了签名校验需同时填 `FEISHU_WEBHOOK_SECRET` |
+| 容器内找不到输出 | 确认挂载到 `/app/output`(`OUTPUT_DIR`),`docker compose` 已默认如此 |
+| 改了 `.env` 不生效 | 重启服务:`docker compose up -d`(环境变量在容器启动时注入) |
+
+---
+
+## 12. 目录速查
+
+```
+apps/cli/            CLI 入口(Commander)
+apps/web/            Next.js WebUI + API 路由
+packages/shared/     Zod schema、类型、常量、LLM 客户端、路径/发布配置解析
+packages/core/       流水线编排、7 阶段、cleanup、publish(OSS+飞书)
+packages/tts/        TTS provider 注册表
+packages/templates/  Remotion 场景组件
+packages/collect/    数据采集器(github-trending 等)
+config/default.yaml  业务配置(provider / 模板配色 / 采集源 / OSS·飞书默认值)
+.env(.example)       密钥与端点
+docker-compose.yml   容器编排
+Dockerfile           多阶段构建
+```

+ 1 - 0
packages/core/package.json

@@ -21,6 +21,7 @@
     "@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"
   },

+ 84 - 0
packages/core/src/cleanup.ts

@@ -0,0 +1,84 @@
+import { readdir, stat, rm } from "node:fs/promises";
+import { join } from "node:path";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export interface CleanupResult {
+  scanned: number;
+  removed: number;
+  bytesFreed: number;
+  skipped?: string;
+}
+
+/**
+ * Recursively remove files (and the empty directories that held them) whose
+ * mtime is older than `retentionDays`. Best-effort: never throws, so a sweep
+ * failure can never break a render.
+ *
+ * The output tree is organized as `{outputDir}/{template}/{ISO-date}/{file}`,
+ * so once a day's files expire the whole `{template}/{date}` subtree is pruned.
+ */
+export async function cleanupExpiredOutput(
+  outputDir: string,
+  retentionDays: number,
+  now: number = Date.now()
+): Promise<CleanupResult> {
+  const result: CleanupResult = { scanned: 0, removed: 0, bytesFreed: 0 };
+  if (!retentionDays || retentionDays <= 0) {
+    return { ...result, skipped: "retention disabled" };
+  }
+  const cutoff = now - retentionDays * DAY_MS;
+  try {
+    await sweepDir(outputDir, cutoff, result);
+  } catch {
+    // Swallow — cleanup is opportunistic.
+  }
+  return result;
+}
+
+async function sweepDir(
+  dir: string,
+  cutoff: number,
+  result: CleanupResult
+): Promise<void> {
+  let entries;
+  try {
+    entries = await readdir(dir, { withFileTypes: true });
+  } catch {
+    return;
+  }
+
+  for (const entry of entries) {
+    const full = join(dir, entry.name);
+    let s;
+    try {
+      s = await stat(full);
+    } catch {
+      continue;
+    }
+    result.scanned += 1;
+
+    if (s.isDirectory()) {
+      await sweepDir(full, cutoff, result);
+      // Drop the directory if it is now empty and itself predates the cutoff.
+      try {
+        const remaining = await readdir(full);
+        if (remaining.length === 0 && s.mtimeMs < cutoff) {
+          // rm needs recursive:true to remove a (here, verified-empty) directory.
+          await rm(full, { recursive: true });
+          result.removed += 1;
+        }
+      } catch {
+        // ignore
+      }
+    } else if (s.isFile() && s.mtimeMs < cutoff) {
+      try {
+        await rm(full);
+        result.removed += 1;
+        result.bytesFreed += s.size;
+      } catch {
+        // ignore
+      }
+    }
+  }
+}

+ 10 - 0
packages/core/src/index.ts

@@ -1,2 +1,12 @@
 export { runPipeline } from "./pipeline.js";
 export type { PipelineConfig, PipelineCallbacks } from "./pipeline.js";
+export { resolvePublishConfig } from "./publish/index.js";
+export type {
+  PublishConfig,
+  PublishContext,
+  PublishOutcome,
+  OssConfig,
+  FeishuConfig,
+} from "./publish/index.js";
+export { cleanupExpiredOutput } from "./cleanup.js";
+export type { CleanupResult } from "./cleanup.js";

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

@@ -13,6 +13,12 @@ import { resolveAssets } from "./stages/assets.js";
 import { composeProject } from "./stages/compose.js";
 import { renderVideo } from "./stages/render.js";
 import { exportVideo } from "./stages/export.js";
+import { cleanupExpiredOutput } from "./cleanup.js";
+import {
+  runPublish,
+  notifyGenerationFailure,
+  type PublishConfig,
+} from "./publish/index.js";
 
 export interface PipelineConfig {
   branding: {
@@ -37,7 +43,13 @@ export interface PipelineConfig {
   };
   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;
+  /** Skip the publish stage entirely (e.g. local debugging). */
+  skipPublish?: boolean;
   assets: {
     root: string;
     inputDir: string;
@@ -64,6 +76,12 @@ export async function runPipeline(
   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) {
+    await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
+  }
+
   const platforms = input.platforms;
   const job: PipelineJob = {
     id: jobId,
@@ -137,10 +155,50 @@ export async function runPipeline(
 
     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) {
+      callbacks?.onStageStart?.("publish");
+      const publishCtx = {
+        jobId,
+        template: input.template,
+        platforms,
+      };
+      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;
+          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;
+        callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
+      }
+    }
   } catch (err) {
     job.status = "failed";
     job.error = err instanceof Error ? err.message : String(err);
     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 },
+        job.error ?? "unknown error"
+      ).catch(() => {});
+    }
   }
 
   job.updatedAt = Date.now();

+ 52 - 0
packages/core/src/publish/feishu.ts

@@ -0,0 +1,52 @@
+import { createHmac } from "node:crypto";
+
+/**
+ * Feishu (Lark) custom-bot webhook configuration.
+ * `webhook` and (optional) `secret` are typically supplied via env vars
+ * FEISHU_WEBHOOK_URL / FEISHU_WEBHOOK_SECRET.
+ */
+export interface FeishuConfig {
+  webhook: string;
+  /** Optional signing secret (custom-bot "签名校验"). */
+  secret?: string;
+}
+
+/**
+ * Produce the timestamp + HMAC-SHA256 signature Feishu expects for signed bots.
+ * Algorithm: sign = base64(HMAC_SHA256(key = `${timestamp}\n${secret}`, message = "")).
+ */
+function sign(secret: string): { timestamp: string; sign: string } {
+  const timestamp = Math.floor(Date.now() / 1000).toString();
+  const stringToSign = `${timestamp}\n${secret}`;
+  const signature = createHmac("sha256", stringToSign).update("").digest("base64");
+  return { timestamp, sign: signature };
+}
+
+/**
+ * Send a plain-text message to the Feishu webhook. Throws on transport failure
+ * or a non-zero Feishu business code so callers can surface the error.
+ */
+export async function sendFeishuMessage(config: FeishuConfig, text: string): Promise<void> {
+  let url = config.webhook;
+  if (config.secret) {
+    const { timestamp, sign: signature } = sign(config.secret);
+    const sep = url.includes("?") ? "&" : "?";
+    url = `${url}${sep}timestamp=${timestamp}&sign=${encodeURIComponent(signature)}`;
+  }
+
+  const res = await fetch(url, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ msg_type: "text", content: { text } }),
+  });
+
+  if (!res.ok) {
+    const body = await res.text().catch(() => "");
+    throw new Error(`Feishu webhook HTTP ${res.status}: ${body}`);
+  }
+
+  const data = (await res.json().catch(() => null)) as { code?: number; msg?: string } | null;
+  if (data && typeof data.code === "number" && data.code !== 0) {
+    throw new Error(`Feishu webhook rejected message: code=${data.code} msg=${data.msg ?? ""}`);
+  }
+}

+ 176 - 0
packages/core/src/publish/index.ts

@@ -0,0 +1,176 @@
+import { isoDateString, type ExportFile } from "@pipeline/shared";
+import { getTimezone } from "@pipeline/shared/node";
+import { uploadToOss, type OssConfig } from "./oss.js";
+import { sendFeishuMessage, type FeishuConfig } from "./feishu.js";
+
+export type { OssConfig, FeishuConfig };
+
+export interface PublishConfig {
+  oss?: OssConfig;
+  feishu?: FeishuConfig;
+}
+
+export interface PublishContext {
+  jobId: string;
+  template: string;
+  platforms: 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 {
+  if (value === undefined || value === "") return fallback;
+  return value === "1" || value.toLowerCase() === "true";
+}
+
+/**
+ * Build a PublishConfig from the raw YAML config merged with environment
+ * variables. Env vars take precedence and are the recommended place for secrets.
+ * Returns undefined when neither OSS nor Feishu is usable.
+ */
+export function resolvePublishConfig(
+  raw?: Record<string, any> | null
+): PublishConfig | undefined {
+  const ossRaw = raw?.oss ?? {};
+
+  const accessKeyId = process.env.OSS_ACCESS_KEY_ID || ossRaw.accessKeyId || "";
+  const accessKeySecret =
+    process.env.OSS_ACCESS_KEY_SECRET || ossRaw.accessKeySecret || "";
+  const region = process.env.OSS_REGION || ossRaw.region || "";
+  const bucket = process.env.OSS_BUCKET || ossRaw.bucket || "";
+
+  const oss: OssConfig = {
+    region,
+    bucket,
+    accessKeyId,
+    accessKeySecret,
+    endpoint: process.env.OSS_ENDPOINT || ossRaw.endpoint,
+    path: process.env.OSS_PATH || ossRaw.path,
+    publicDomain: process.env.OSS_PUBLIC_DOMAIN || ossRaw.publicDomain,
+    secure: parseBool(process.env.OSS_SECURE, ossRaw.secure ?? true),
+  };
+  const ossEnabled = Boolean(oss.region && oss.bucket && oss.accessKeyId && oss.accessKeySecret);
+
+  const feishuRaw = raw?.feishu ?? {};
+  const webhook = process.env.FEISHU_WEBHOOK_URL || feishuRaw.webhook || "";
+  const feishu: FeishuConfig = {
+    webhook,
+    secret: process.env.FEISHU_WEBHOOK_SECRET || feishuRaw.secret,
+  };
+  const feishuEnabled = Boolean(feishu.webhook);
+
+  if (!ossEnabled && !feishuEnabled) return undefined;
+  return {
+    oss: ossEnabled ? oss : undefined,
+    feishu: feishuEnabled ? feishu : undefined,
+  };
+}
+
+async function safeNotify(config: FeishuConfig, text: string): Promise<void> {
+  try {
+    await sendFeishuMessage(config, text);
+  } catch {
+    // Notification is best-effort; never let it mask the real outcome.
+  }
+}
+
+function buildSuccessText(
+  ctx: PublishContext,
+  uploaded: UploadedFile[],
+  files: ExportFile[]
+): string {
+  const lines = uploaded.length
+    ? uploaded.map((u) => `- [${u.platform}] ${u.ossUrl}`)
+    : files.map((f) => `- [${f.platform}] ${f.filePath}`);
+  return [
+    "✅ 视频生成成功",
+    `模板: ${ctx.template}`,
+    `平台: ${ctx.platforms.join(", ")}`,
+    `任务: ${ctx.jobId}`,
+    "资源:",
+    ...lines,
+  ].join("\n");
+}
+
+function buildUploadFailureText(
+  ctx: PublishContext,
+  files: ExportFile[],
+  error: string
+): string {
+  return [
+    "⚠️ 视频已生成,但 OSS 上传失败",
+    `模板: ${ctx.template}`,
+    `平台: ${ctx.platforms.join(", ")}`,
+    `任务: ${ctx.jobId}`,
+    `本地文件: ${files.map((f) => f.filePath).join(", ")}`,
+    `错误: ${error}`,
+  ].join("\n");
+}
+
+function buildGenerationFailureText(ctx: PublishContext, error: string): string {
+  return [
+    "❌ 视频生成失败",
+    `模板: ${ctx.template}`,
+    `平台: ${ctx.platforms.join(", ")}`,
+    `任务: ${ctx.jobId}`,
+    `错误: ${error}`,
+  ].join("\n");
+}
+
+/**
+ * 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.
+ */
+export async function runPublish(
+  files: ExportFile[],
+  publish: PublishConfig,
+  ctx: PublishContext
+): Promise<PublishOutcome> {
+  const dateDir = isoDateString(new Date(), getTimezone());
+  const uploaded: UploadedFile[] = [];
+
+  if (publish.oss) {
+    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 });
+      }
+    } catch (err) {
+      const error = err instanceof Error ? err.message : String(err);
+      if (publish.feishu) {
+        await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error));
+      }
+      return { ok: false, uploaded, error };
+    }
+  }
+
+  if (publish.feishu) {
+    await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
+  }
+
+  return { ok: true, uploaded };
+}
+
+/** Notify Feishu that the render itself failed before any file was produced. */
+export async function notifyGenerationFailure(
+  publish: PublishConfig,
+  ctx: PublishContext,
+  error: string
+): Promise<void> {
+  if (!publish.feishu) return;
+  await safeNotify(publish.feishu, buildGenerationFailureText(ctx, error));
+}

+ 73 - 0
packages/core/src/publish/oss.ts

@@ -0,0 +1,73 @@
+import OSS from "ali-oss";
+import { basename } from "node:path";
+
+/**
+ * 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 {
+  /** Object key within the bucket. */
+  key: string;
+  /** Public resource URL (publicDomain/key, or the bucket's default domain). */
+  url: string;
+}
+
+function joinKey(prefix: string | undefined, ...parts: string[]): string {
+  return [prefix, ...parts]
+    .filter((s): s is string => Boolean(s))
+    .map((s) => s.replace(/^\/+|\/+$/g, ""))
+    .filter(Boolean)
+    .join("/");
+}
+
+/**
+ * Upload a single local file to OSS and return its public URL.
+ * Uses multipart upload so large MP4s are handled reliably.
+ */
+export async function uploadToOss(
+  localPath: string,
+  config: OssConfig,
+  opts?: { dateDir?: string }
+): Promise<OssUploadResult> {
+  const client = new OSS({
+    region: config.region,
+    bucket: config.bucket,
+    accessKeyId: config.accessKeyId,
+    accessKeySecret: config.accessKeySecret,
+    endpoint: config.endpoint,
+    secure: config.secure ?? true,
+    timeout: 600000,
+  });
+
+  const filename = basename(localPath);
+  const key = joinKey(config.path, opts?.dateDir ?? "", filename);
+
+  const result = await client.multipartUpload(key, localPath, {
+    partSize: 5 * 1024 * 1024, // 5MB parts
+    timeout: 600000,
+  });
+
+  const url = config.publicDomain
+    ? `${config.publicDomain.replace(/\/+$/, "")}/${key}`
+    : result.url || key;
+
+  return { key, url };
+}

+ 8 - 2
packages/core/src/stages/export.ts

@@ -1,4 +1,6 @@
 import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
+import { isoDateString } from "@pipeline/shared";
+import { getTimezone } from "@pipeline/shared/node";
 import { join } from "node:path";
 import { mkdir } from "node:fs/promises";
 
@@ -8,13 +10,17 @@ export async function exportVideo(
   outputDir: string,
   jobId: string
 ): Promise<ExportManifest> {
-  await mkdir(outputDir, { recursive: true });
+  // 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 filename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}.mp4`;
-  const finalPath = join(outputDir, filename);
+  const finalPath = join(subDir, filename);
   await copyFile(renderedPath, finalPath);
 
   const files: ExportFile[] = [

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

@@ -6,7 +6,9 @@ import {
   getParsePrompt,
   stripUnsupportedGlyphs,
   clipToLength,
+  formatChineseDate,
 } from "@pipeline/shared";
+import { getTimezone } from "@pipeline/shared/node";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
 export interface ParseStageConfig {
@@ -19,12 +21,6 @@ export interface ParseStageConfig {
   source?: string;
 }
 
-/** Today's date formatted as e.g. "2026年6月19日" (zh-CN, no leading zeros). */
-function formatTodayChineseDate(): string {
-  const d = new Date();
-  return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
-}
-
 /**
  * Clip LLM-generated text fields to their schema-enforced maximums before
  * validation. Clips at a sentence/clause boundary when possible (see
@@ -228,7 +224,7 @@ export async function parseText(
     videoInput = {
       ...videoInput,
       title: "GitHub 每日热榜",
-      subtitle: formatTodayChineseDate(),
+      subtitle: formatChineseDate(new Date(), getTimezone()),
     };
   }
 
@@ -265,7 +261,7 @@ export async function parseText(
   // Other templates keep the original behaviour (1s silent cover).
   const gtRepoCount = videoInput.scenes.filter(s => s.github).length;
   const coverNarration = template === "github-trending"
-    ? `大家好,今天是${formatTodayChineseDate()},欢迎收看 GitHub 每日热榜。今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目。`
+    ? `大家好,今天是${formatChineseDate(new Date(), getTimezone())},欢迎收看 GitHub 每日热榜。今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目。`
     : "";
   scenes.push({
     id: "cover",

+ 39 - 0
packages/core/src/types/ali-oss.d.ts

@@ -0,0 +1,39 @@
+// Minimal ambient types for `ali-oss`. We vendor these instead of depending on
+// `@types/ali-oss` to keep type-resolution robust across installs.
+declare module "ali-oss" {
+  export interface OSSOptions {
+    region: string;
+    bucket: string;
+    accessKeyId: string;
+    accessKeySecret: string;
+    endpoint?: string;
+    secure?: boolean;
+    timeout?: number;
+    [key: string]: unknown;
+  }
+
+  export interface MultipartUploadResult {
+    name: string;
+    url: string;
+    bucket?: string;
+    res: { status: number; headers?: Record<string, string> };
+  }
+
+  export interface MultipartUploadOptions {
+    partSize?: number;
+    timeout?: number;
+    headers?: Record<string, string>;
+    progress?: (p: number, checkpoint?: unknown) => void;
+  }
+
+  export default class OSS {
+    constructor(options: OSSOptions);
+    multipartUpload(
+      name: string,
+      file: string,
+      options?: MultipartUploadOptions
+    ): Promise<MultipartUploadResult>;
+    put(name: string, file: string): Promise<MultipartUploadResult>;
+    delete(name: string): Promise<unknown>;
+  }
+}

+ 4 - 0
packages/shared/package.json

@@ -9,6 +9,10 @@
     ".": {
       "import": "./dist/index.js",
       "types": "./dist/index.d.ts"
+    },
+    "./node": {
+      "import": "./dist/node.js",
+      "types": "./dist/node.d.ts"
     }
   },
   "scripts": {

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

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

+ 5 - 0
packages/shared/src/node.ts

@@ -0,0 +1,5 @@
+// Node-only entry point (`@pipeline/shared/node`). These helpers use `node:fs`
+// / `node:path` / `process.env` and must not be imported into client bundles.
+export { findMonorepoRoot, resolveOutputDir } from "./utils/paths.js";
+export { isoDateString, formatChineseDate } from "./utils/date.js";
+export { getTimezone } from "./utils/timezone.js";

+ 3 - 0
packages/shared/src/types/pipeline.ts

@@ -115,6 +115,7 @@ export const ExportFileSchema = z.object({
   durationSeconds: z.number(),
   format: z.literal("mp4"),
   codec: z.literal("h264"),
+  ossUrl: z.string().optional(),
 });
 
 export type ExportFile = z.infer<typeof ExportFileSchema>;
@@ -137,6 +138,8 @@ export const PipelineJobSchema = z.object({
   tts: TTSStageResultSchema.optional(),
   composed: z.record(z.string(), ComposedProjectSchema).optional(),
   exported: ExportManifestSchema.optional(),
+  /** Set when the publish stage (OSS upload / Feishu notify) failed after render. */
+  publishError: z.string().optional(),
   createdAt: z.number(),
   updatedAt: z.number(),
 });

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

@@ -0,0 +1,55 @@
+/**
+ * Timezone-aware date helpers for a Beijing-daily product.
+ *
+ * Defaults are pinned to `Asia/Shanghai` (override at runtime via the
+ * `TIMEZONE` env var — read by `getTimezone()` in `@pipeline/shared/node`) so
+ * the date stays correct inside containers whose system timezone is UTC. A
+ * container running at Beijing 00:30 reports the *previous* day under UTC; the
+ * helpers here report the calendar day in the configured timezone instead.
+ *
+ * This module is PURE (standard `Intl` API only, no `process.env`) so it is
+ * safe to import from client bundles. The Node-side env read lives in
+ * `utils/timezone.ts`, re-exported through `@pipeline/shared/node`.
+ */
+export const DEFAULT_TIMEZONE = "Asia/Shanghai";
+
+const PARTS_DATE = { year: "numeric", month: "numeric", day: "numeric" } as const;
+const PARTS_DATE_PADDED = { year: "numeric", month: "2-digit", day: "2-digit" } as const;
+
+function dateParts(
+  date: Date,
+  timeZone: string,
+  opts: Readonly<{ year: "numeric"; month: "numeric" | "2-digit"; day: "numeric" | "2-digit" }>,
+): Intl.DateTimeFormatPart[] {
+  return new Intl.DateTimeFormat("en-US", { ...opts, timeZone }).formatToParts(date);
+}
+
+function part(parts: Intl.DateTimeFormatPart[], type: string): string {
+  return parts.find((p) => p.type === type)?.value ?? "";
+}
+
+/**
+ * A date formatted as e.g. "2026年7月18日" (zh-CN, no leading zeros) in the
+ * given timezone. Defaults to Asia/Shanghai.
+ */
+export function formatChineseDate(
+  date: Date = new Date(),
+  timeZone: string = DEFAULT_TIMEZONE,
+): string {
+  const p = dateParts(date, timeZone, PARTS_DATE);
+  return `${part(p, "year")}年${Number(part(p, "month"))}月${Number(part(p, "day"))}日`;
+}
+
+/**
+ * ISO-8601 date (YYYY-MM-DD) in the given timezone (default Asia/Shanghai, NOT
+ * UTC). Used for the per-day output subdirectory so the folder a video is filed
+ * under matches the date shown on its cover. Stable across hosts/containers
+ * because the timezone is fixed rather than inherited from the host clock.
+ */
+export function isoDateString(
+  date: Date = new Date(),
+  timeZone: string = DEFAULT_TIMEZONE,
+): string {
+  const p = dateParts(date, timeZone, PARTS_DATE_PADDED);
+  return `${part(p, "year")}-${part(p, "month")}-${part(p, "day")}`;
+}

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

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

+ 40 - 0
packages/shared/src/utils/paths.ts

@@ -0,0 +1,40 @@
+import { existsSync } from "node:fs";
+import { resolve, isAbsolute } from "node:path";
+
+// IMPORTANT: this module imports `node:fs` / `node:path` and must NEVER be
+// re-exported from the main `@pipeline/shared` barrel (which client bundles
+// import). Reach it only via the `@pipeline/shared/node` subpath.
+
+// Files that mark the monorepo root. Used so CLI (cwd = repo root) and the web
+// app (cwd = apps/web) — and the Docker container (cwd = /app/apps/web) — all
+// resolve the same unified output directory.
+const ROOT_MARKERS = ["pnpm-workspace.yaml", "turbo.json"];
+
+/**
+ * Walk up from `from` (default: cwd) until a monorepo marker file is found.
+ * Falls back to `from` when no marker is discovered (e.g. running outside the repo).
+ */
+export function findMonorepoRoot(from: string = process.cwd()): string {
+  let dir = resolve(from);
+  for (let i = 0; i < 8; i++) {
+    if (ROOT_MARKERS.some((m) => existsSync(resolve(dir, m)))) {
+      return dir;
+    }
+    const parent = resolve(dir, "..");
+    if (parent === dir) break;
+    dir = parent;
+  }
+  return resolve(from);
+}
+
+/**
+ * Resolve the unified output base directory.
+ *
+ * Resolution order: `OUTPUT_DIR` env var > `configured` (config/default.yaml
+ * `output.dir`) > `./output`. Relative paths are resolved against the monorepo
+ * root (not cwd), so CLI / HTTP / web all land in the same physical directory.
+ */
+export function resolveOutputDir(configured?: string): string {
+  const raw = process.env.OUTPUT_DIR || configured || "./output";
+  return isAbsolute(raw) ? raw : resolve(findMonorepoRoot(), raw);
+}

+ 12 - 0
packages/shared/src/utils/timezone.ts

@@ -0,0 +1,12 @@
+// Node-only: reads `process.env`. Re-exported via `@pipeline/shared/node`,
+// never from the client-safe main barrel.
+import { DEFAULT_TIMEZONE } from "./date.js";
+
+/**
+ * Timezone used for every displayed and folder date. Defaults to Asia/Shanghai
+ * (correct for a Beijing-daily product regardless of the host/container clock);
+ * override with the `TIMEZONE` env var (e.g. `TIMEZONE=America/Los_Angeles`).
+ */
+export function getTimezone(): string {
+  return process.env.TIMEZONE || DEFAULT_TIMEZONE;
+}

+ 4 - 3
packages/tts/src/providers/elevenlabs.ts

@@ -8,6 +8,7 @@ import type {
 import type { TTSProvider } from "../types.js";
 import { registerProvider } from "../registry.js";
 import { measureAudioDuration } from "../duration.js";
+import { fetchWithRetry } from "../retry.js";
 
 class ElevenLabsProvider implements TTSProvider {
   readonly name = "elevenlabs";
@@ -19,7 +20,7 @@ class ElevenLabsProvider implements TTSProvider {
     const model = process.env.ELEVENLABS_MODEL || "eleven_multilingual_v2";
     const voiceId = request.voiceId || "21m00Tcm4TlvDq8ikWAM"; // Rachel default
 
-    const response = await fetch(
+    const response = await fetchWithRetry(
       `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
       {
         method: "POST",
@@ -71,7 +72,7 @@ class ElevenLabsProvider implements TTSProvider {
     const apiKey = process.env.ELEVENLABS_API_KEY;
     if (!apiKey) throw new Error("ELEVENLABS_API_KEY not set");
 
-    const response = await fetch(
+    const response = await fetchWithRetry(
       "https://api.elevenlabs.io/v1/voices",
       {
         headers: { "xi-api-key": apiKey },
@@ -108,7 +109,7 @@ class ElevenLabsProvider implements TTSProvider {
     formData.append("audio", new Blob([audio]), "audio.mp3");
     formData.append("text", text);
 
-    const response = await fetch(
+    const response = await fetchWithRetry(
       "https://api.elevenlabs.io/v1/forced-alignment",
       {
         method: "POST",

+ 3 - 2
packages/tts/src/providers/fish-audio.ts

@@ -8,6 +8,7 @@ import type {
 import type { TTSProvider } from "../types.js";
 import { registerProvider } from "../registry.js";
 import { measureAudioDuration } from "../duration.js";
+import { fetchWithRetry } from "../retry.js";
 
 class FishAudioProvider implements TTSProvider {
   readonly name = "fish-audio";
@@ -17,7 +18,7 @@ class FishAudioProvider implements TTSProvider {
     if (!apiKey) throw new Error("FISH_AUDIO_API_KEY not set");
 
     const model = process.env.FISH_AUDIO_MODEL || "s2-pro";
-    const response = await fetch("https://api.fish.audio/v1/tts", {
+    const response = await fetchWithRetry("https://api.fish.audio/v1/tts", {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
@@ -64,7 +65,7 @@ class FishAudioProvider implements TTSProvider {
     if (filter?.language) params.set("language", filter.language);
     params.set("page_size", "50");
 
-    const response = await fetch(
+    const response = await fetchWithRetry(
       `https://api.fish.audio/v1/model?${params}`,
       {
         headers: { Authorization: `Bearer ${apiKey}` },

+ 2 - 1
packages/tts/src/providers/minimax.ts

@@ -8,6 +8,7 @@ import type {
 import type { TTSProvider } from "../types.js";
 import { registerProvider } from "../registry.js";
 import { measureAudioDuration } from "../duration.js";
+import { fetchWithRetry } from "../retry.js";
 
 class MiniMaxProvider implements TTSProvider {
   readonly name = "minimax";
@@ -20,7 +21,7 @@ class MiniMaxProvider implements TTSProvider {
     }
 
     const model = process.env.MINIMAX_MODEL || "speech-02-hd";
-    const response = await fetch(
+    const response = await fetchWithRetry(
       `https://api.minimax.chat/v1/t2a_v2?GroupId=${groupId}`,
       {
         method: "POST",

+ 2 - 1
packages/tts/src/providers/openai-tts.ts

@@ -7,6 +7,7 @@ import type {
 import type { TTSProvider } from "../types.js";
 import { registerProvider } from "../registry.js";
 import { measureAudioDuration } from "../duration.js";
+import { fetchWithRetry } from "../retry.js";
 
 class OpenAITTSProvider implements TTSProvider {
   readonly name = "openai-tts";
@@ -20,7 +21,7 @@ class OpenAITTSProvider implements TTSProvider {
       "https://new-api.corp.shuidi.tech/v1";
     const model = process.env.OPENAI_TTS_MODEL || "seed-tts-1.1";
 
-    const response = await fetch(`${baseURL}/audio/speech`, {
+    const response = await fetchWithRetry(`${baseURL}/audio/speech`, {
       method: "POST",
       headers: {
         "Content-Type": "application/json",

+ 80 - 0
packages/tts/src/retry.ts

@@ -0,0 +1,80 @@
+import { setTimeout as sleep } from "node:timers/promises";
+
+const DEFAULT_RETRIES = 3;
+const DEFAULT_BASE_DELAY_MS = 800;
+const DEFAULT_MAX_DELAY_MS = 8000;
+
+export interface RetryOptions {
+  /** Retries after the initial attempt (default 3). 0 disables retry. */
+  retries?: number;
+  baseDelayMs?: number;
+  maxDelayMs?: number;
+}
+
+/** Read `TTS_MAX_RETRIES` if set, else the provided default. */
+function resolveRetries(fallback: number): number {
+  const raw = process.env.TTS_MAX_RETRIES;
+  if (raw === undefined || raw === "") return fallback;
+  const n = Number(raw);
+  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
+}
+
+/** HTTP statuses worth retrying: rate limiting + server errors. */
+export function isRetriableStatus(status: number): boolean {
+  return status === 429 || (status >= 500 && status <= 599);
+}
+
+function delayFor(attempt: number, base: number, max: number): number {
+  const exp = Math.min(max, base * 2 ** attempt);
+  return Math.floor(Math.random() * (exp - base + 1)) + base; // full jitter
+}
+
+/**
+ * Drop-in replacement for `fetch` that retries transient failures — network
+ * errors and HTTP 429/5xx — with exponential backoff + jitter. TTS gateways
+ * intermittently return 502/503 (upstream DNS, rolling restarts); without
+ * retry a single blip fails the whole render.
+ *
+ * Returns the final `Response` (ok, or a non-retriable error such as 4xx) so
+ * the caller can read its body. When retries are exhausted on a retriable
+ * status the last response is still returned (body intact) for diagnostics.
+ */
+export async function fetchWithRetry(
+  input: string | URL,
+  init: RequestInit,
+  opts: RetryOptions = {}
+): Promise<Response> {
+  const retries = resolveRetries(opts.retries ?? DEFAULT_RETRIES);
+  const baseDelay = opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
+  const maxDelay = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
+
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    let response: Response;
+    try {
+      response = await fetch(input, init);
+    } catch (err) {
+      if (attempt >= retries) throw err;
+      console.warn(
+        `[tts] request failed (${err instanceof Error ? err.message : err}), retrying ${attempt + 1}/${retries}`
+      );
+      await sleep(delayFor(attempt, baseDelay, maxDelay));
+      continue;
+    }
+
+    if (response.ok || !isRetriableStatus(response.status)) {
+      return response;
+    }
+
+    if (attempt >= retries) {
+      return response; // exhausted — leave body intact for the caller to read
+    }
+
+    // Retriable status with retries left: drain the body to free the socket,
+    // then back off.
+    await response.arrayBuffer().catch(() => {});
+    console.warn(`[tts] HTTP ${response.status}, retrying ${attempt + 1}/${retries}`);
+    await sleep(delayFor(attempt, baseDelay, maxDelay));
+  }
+
+  throw new Error("fetchWithRetry exhausted retries unexpectedly");
+}

File diff suppressed because it is too large
+ 462 - 0
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff