vodkazi 1 月之前
當前提交
db8e72ef1c
共有 100 個文件被更改,包括 6346 次插入0 次删除
  1. 20 0
      .env.example
  2. 20 0
      .gitignore
  3. 2 0
      .npmrc
  4. 76 0
      CLAUDE.md
  5. 27 0
      apps/cli/package.json
  6. 43 0
      apps/cli/src/commands/init.ts
  7. 29 0
      apps/cli/src/commands/preview.ts
  8. 151 0
      apps/cli/src/commands/render.ts
  9. 32 0
      apps/cli/src/commands/templates.ts
  10. 39 0
      apps/cli/src/commands/voices.ts
  11. 41 0
      apps/cli/src/index.ts
  12. 15 0
      apps/cli/tsconfig.json
  13. 26 0
      apps/web/next.config.ts
  14. 23 0
      apps/web/package.json
  15. 37 0
      apps/web/src/app/api/download/route.ts
  16. 14 0
      apps/web/src/app/api/jobs/[id]/route.ts
  17. 6 0
      apps/web/src/app/api/jobs/route.ts
  18. 109 0
      apps/web/src/app/api/render/route.ts
  19. 101 0
      apps/web/src/app/api/settings/route.ts
  20. 280 0
      apps/web/src/app/create/page.tsx
  21. 298 0
      apps/web/src/app/globals.css
  22. 182 0
      apps/web/src/app/jobs/[id]/page.tsx
  23. 85 0
      apps/web/src/app/jobs/page.tsx
  24. 34 0
      apps/web/src/app/layout.tsx
  25. 58 0
      apps/web/src/app/page.tsx
  26. 203 0
      apps/web/src/app/settings/page.tsx
  27. 74 0
      apps/web/src/app/templates/page.tsx
  28. 13 0
      apps/web/src/lib/api-client.ts
  29. 88 0
      apps/web/src/lib/job-store.ts
  30. 40 0
      apps/web/tsconfig.json
  31. 二進制
      assets/backgrounds/default-knowledge-16x9.png
  32. 二進制
      assets/backgrounds/default-knowledge-9x16.png
  33. 二進制
      assets/backgrounds/default-marketing-16x9.png
  34. 二進制
      assets/backgrounds/default-marketing-9x16.png
  35. 二進制
      assets/backgrounds/default-news-16x9.png
  36. 二進制
      assets/backgrounds/default-news-9x16.png
  37. 二進制
      assets/backgrounds/default-opinion-16x9.png
  38. 二進制
      assets/backgrounds/default-opinion-9x16.png
  39. 二進制
      assets/backgrounds/default.png
  40. 二進制
      assets/fonts/NotoSansSC-Bold.ttf
  41. 二進制
      assets/fonts/NotoSansSC-Regular.ttf
  42. 47 0
      config/default.yaml
  43. 18 0
      package.json
  44. 30 0
      packages/core/package.json
  45. 2 0
      packages/core/src/index.ts
  46. 126 0
      packages/core/src/pipeline.ts
  47. 128 0
      packages/core/src/stages/assets.ts
  48. 90 0
      packages/core/src/stages/compose.ts
  49. 38 0
      packages/core/src/stages/export.ts
  50. 80 0
      packages/core/src/stages/parse.ts
  51. 110 0
      packages/core/src/stages/render.ts
  52. 131 0
      packages/core/src/stages/tts.ts
  53. 14 0
      packages/core/tsconfig.json
  54. 23 0
      packages/shared/package.json
  55. 40 0
      packages/shared/src/constants.ts
  56. 16 0
      packages/shared/src/index.ts
  57. 36 0
      packages/shared/src/llm/client.ts
  58. 3 0
      packages/shared/src/llm/index.ts
  59. 75 0
      packages/shared/src/llm/prompts/parse-text.ts
  60. 32 0
      packages/shared/src/types/asset.ts
  61. 51 0
      packages/shared/src/types/index.ts
  62. 137 0
      packages/shared/src/types/pipeline.ts
  63. 119 0
      packages/shared/src/types/scene.ts
  64. 48 0
      packages/shared/src/types/tts.ts
  65. 8 0
      packages/shared/tsconfig.json
  66. 26 0
      packages/templates/package.json
  67. 312 0
      packages/templates/src/Root.tsx
  68. 72 0
      packages/templates/src/base/components/animated-text.tsx
  69. 65 0
      packages/templates/src/base/components/background.tsx
  70. 76 0
      packages/templates/src/base/components/lower-third.tsx
  71. 43 0
      packages/templates/src/base/components/progress-bar.tsx
  72. 42 0
      packages/templates/src/base/components/split-layout.tsx
  73. 122 0
      packages/templates/src/base/components/subtitle-bar.tsx
  74. 79 0
      packages/templates/src/base/components/transition.tsx
  75. 36 0
      packages/templates/src/base/components/watermark.tsx
  76. 43 0
      packages/templates/src/base/hooks/use-layout.ts
  77. 45 0
      packages/templates/src/base/theme/colors.ts
  78. 4 0
      packages/templates/src/entry.ts
  79. 2 0
      packages/templates/src/index.ts
  80. 91 0
      packages/templates/src/knowledge/components/key-point-card.tsx
  81. 64 0
      packages/templates/src/knowledge/components/step-indicator.tsx
  82. 124 0
      packages/templates/src/knowledge/index.tsx
  83. 81 0
      packages/templates/src/marketing/components/cta-overlay.tsx
  84. 91 0
      packages/templates/src/marketing/components/product-showcase.tsx
  85. 140 0
      packages/templates/src/marketing/index.tsx
  86. 66 0
      packages/templates/src/news/components/headline-card.tsx
  87. 64 0
      packages/templates/src/news/components/news-ticker.tsx
  88. 30 0
      packages/templates/src/news/components/source-citation.tsx
  89. 187 0
      packages/templates/src/news/index.tsx
  90. 74 0
      packages/templates/src/opinion/components/author-card.tsx
  91. 96 0
      packages/templates/src/opinion/components/quote-block.tsx
  92. 135 0
      packages/templates/src/opinion/index.tsx
  93. 23 0
      packages/templates/tsconfig.json
  94. 26 0
      packages/tts/package.json
  95. 38 0
      packages/tts/src/duration.ts
  96. 8 0
      packages/tts/src/index.ts
  97. 154 0
      packages/tts/src/providers/elevenlabs.ts
  98. 123 0
      packages/tts/src/providers/fish-audio.ts
  99. 106 0
      packages/tts/src/providers/minimax.ts
  100. 90 0
      packages/tts/src/providers/openai-tts.ts

+ 20 - 0
.env.example

@@ -0,0 +1,20 @@
+# Environment variables (copy to .env and fill in values)
+
+# LLM
+OPENAI_API_KEY=
+OPENAI_BASE_URL=https://api.openai.com/v1
+
+# TTS — OpenAI TTS (default)
+OPENAI_TTS_API_KEY=
+OPENAI_TTS_BASE_URL=https://new-api.corp.shuidi.tech/v1
+OPENAI_TTS_MODEL=seed-tts-1.1
+
+# TTS — Fish Audio
+FISH_AUDIO_API_KEY=
+
+# TTS — MiniMax
+MINIMAX_API_KEY=
+MINIMAX_GROUP_ID=
+
+# TTS — ElevenLabs
+ELEVENLABS_API_KEY=

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+node_modules/
+dist/
+.turbo/
+*.tsbuildinfo
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Next.js
+.next/
+next-env.d.ts
+
+# Output
+output/
+tmp/
+
+# Claude Code
+.claude/settings.local.json

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+onlyBuiltDependencies[]=esbuild
+onlyBuiltDependencies[]=sharp

+ 76 - 0
CLAUDE.md

@@ -0,0 +1,76 @@
+# CLAUDE.md
+
+本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。
+
+## 项目概述
+
+文本转视频生成流水线。接收描述视频结构(封面、场景、结束语)的 JSON 输入,输出带 TTS 旁白、字幕和模板视觉样式的 MP4 视频。
+
+## 常用命令
+
+```bash
+pnpm build          # 构建所有包 (turbo)
+pnpm typecheck      # 类型检查所有包
+pnpm lint           # Lint 所有包
+
+# 运行视频渲染
+node apps/cli/dist/index.js render test/fixtures/sample-knowledge.json -t knowledge -p bilibili
+
+# Web UI 开发服务器
+cd apps/web && pnpm dev
+```
+
+## 架构
+
+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/ 4 个模板的 Remotion React 组件
+```
+
+### 流水线数据流
+
+输入始终是**结构化 JSON** (`VideoInputSchema`)。流水线在 `packages/core/src/stages/` 中依次执行 6 个阶段:
+
+1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)
+2. **tts** — 按场景调用 TTS provider,生成逐场景音频文件 + word timestamps
+3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
+4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间
+5. **render** — 打包 Remotion bundle,将资源复制到 publicDir,通过 `renderMedia` 渲染
+6. **export** — 输出最终 MP4 到指定目录
+
+核心类型链:`VideoInput` → `ParsedContent` → `ComposedProject` → Remotion props。
+
+### Remotion 约束
+
+Remotion 通过其 webpack dev server 提供所有静态资源。**组件中绝不能使用文件系统绝对路径** — 必须使用 `staticFile(filename)`。render 阶段会将所有资源(音频、图片、背景图)复制到统一的 `publicDir`,通过 props 传递文件名。
+
+音频按场景独立播放:每个 `<Sequence>` 包含自己的 `<Audio>` 组件,不使用全局音频轨道。
+
+### TTS Provider 体系
+
+Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册。每个 provider 实现 `TTSProvider` 接口(`synthesize`、`listVoices`、`align`)。新增 provider 的方式:在 `providers/` 下创建文件并在 `index.ts` 中 import。
+
+### 模板系统
+
+4 个模板(news、knowledge、opinion、marketing),每个在 `packages/templates/src/<name>/index.tsx` 中有对应的场景组件。共享基础组件位于 `packages/templates/src/base/`。`Root.tsx` 将 `sceneType === "cover"|"outro"` 路由到内置的 CoverScene/OutroScene;content 类型场景路由到对应模板组件。
+
+## 配置
+
+- `.env`(monorepo 根目录)— 所有 provider 的 API Key(CLI 启动时加载,Next.js 通过 `next.config.ts` 加载)
+- `config/default.yaml` — TTS provider、模型、模板配色、输出设置
+- CLI 按以下顺序读取配置:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`
+
+## 核心 Schema
+
+全部在 `packages/shared/src/types/` 中用 Zod 定义:
+
+- `VideoInputSchema` — 用户输入格式(title、subtitle、cover、scenes[]、outro、globalStyle)
+- `SceneSchema` — 内部场景,含 `sceneType: "cover"|"content"|"outro"`
+- `ComposedSceneSchema` — compose 后的场景,含帧时间轴、`audioPath`、已解析的图片
+- `PipelineInputSchema` — 顶层流水线输入(text、template、platform、ttsProvider)

+ 27 - 0
apps/cli/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "@pipeline/cli",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "bin": {
+    "pipeline": "./dist/index.js"
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@pipeline/core": "workspace:*",
+    "@pipeline/shared": "workspace:*",
+    "@pipeline/tts": "workspace:*",
+    "@pipeline/templates": "workspace:*",
+    "commander": "^13.0.0",
+    "chalk": "^5.4.0",
+    "ora": "^8.2.0",
+    "listr2": "^8.2.0",
+    "yaml": "^2.7.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

+ 43 - 0
apps/cli/src/commands/init.ts

@@ -0,0 +1,43 @@
+import { Command } from "commander";
+import { writeFileSync, existsSync } from "node:fs";
+import { resolve } from "node:path";
+
+const DEFAULT_CONFIG = `# Pipeline default configuration
+llm:
+  baseURL: "https://api.openai.com/v1"
+  model: "gpt-4o"
+
+tts:
+  provider: "openai-tts"
+  openai-tts:
+    model: "seed-tts-1.1"
+    defaultVoice: "zh_female_vv_uranus_bigtts"
+  fish-audio:
+    model: "s2-pro"
+  minimax:
+    model: "speech-02-hd"
+  elevenlabs:
+    model: "eleven_multilingual_v2"
+
+output:
+  dir: "./output"
+  format: "mp4"
+`;
+
+export const initCommand = new Command("init")
+  .description("Create default config file")
+  .option("-o, --output <path>", "Config file path", "pipeline.config.yaml")
+  .action((opts) => {
+    const configPath = resolve(opts.output);
+    if (existsSync(configPath)) {
+      console.error(`Config file already exists: ${configPath}`);
+      process.exit(1);
+    }
+
+    writeFileSync(configPath, DEFAULT_CONFIG, "utf-8");
+    console.log(`Created config file: ${configPath}`);
+    console.log("\nNext steps:");
+    console.log("  1. Edit the config file with your API keys");
+    console.log("  2. Set environment variables (see .env.example)");
+    console.log("  3. Run: pipeline render article.md -t news -p bilibili");
+  });

+ 29 - 0
apps/cli/src/commands/preview.ts

@@ -0,0 +1,29 @@
+import { Command } from "commander";
+import { execSync } from "node:child_process";
+import { resolve, dirname } from "node:path";
+import { existsSync } from "node:fs";
+
+export const previewCommand = new Command("preview")
+  .description("Open Remotion Studio for live preview")
+  .argument("<input>", "Input text file path")
+  .requiredOption("-t, --template <type>", "Template: news|knowledge|opinion|marketing")
+  .option("-p, --platform <preset>", "Platform preset", "bilibili")
+  .action(async (input, opts) => {
+    const templatesDir = resolve(
+      dirname(new URL(import.meta.url).pathname),
+      "../../../../packages/templates"
+    );
+
+    console.log("Starting Remotion Studio...");
+    console.log(`Template: ${opts.template} | Platform: ${opts.platform}`);
+    console.log(`Templates dir: ${templatesDir}\n`);
+
+    try {
+      execSync(`npx remotion studio`, {
+        cwd: templatesDir,
+        stdio: "inherit",
+      });
+    } catch {
+      // Studio was closed
+    }
+  });

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

@@ -0,0 +1,151 @@
+import { Command } from "commander";
+import { runPipeline, type PipelineConfig } from "@pipeline/core";
+import { PLATFORM_PRESETS, TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
+import { readFileSync, existsSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { parse as parseYaml } from "yaml";
+
+// Commander --no-tts sets opts.tts = false (not opts.noTts)
+const skipTts = (opts: any) => opts.tts === false;
+
+export const renderCommand = new Command("render")
+  .description("Generate video from structured JSON input")
+  .argument("<input>", "Input JSON file path")
+  .requiredOption("-t, --template <type>", `Template: ${TEMPLATE_TYPES.join("|")}`)
+  .option("-p, --platform <preset>", "Platform preset", "bilibili")
+  .option("--tts-provider <provider>", "TTS provider")
+  .option("--voice <id>", "Voice ID override")
+  .option("-o, --output <dir>", "Output directory", "./output")
+  .option("--assets-dir <dir>", "Image assets directory (defaults to JSON file location)")
+  .option("--config <path>", "Config file path")
+  .option("--no-tts", "Skip TTS (generate silent video)")
+  .option("-v, --verbose", "Verbose logging")
+  .action(async (input, opts) => {
+    const template = opts.template as string;
+    if (!TEMPLATE_TYPES.includes(template as any)) {
+      console.error(`Error: Invalid template "${template}". Use: ${TEMPLATE_TYPES.join(", ")}`);
+      process.exit(1);
+    }
+
+    const platform = opts.platform as string;
+    if (!PLATFORM_PRESET_KEYS.includes(platform as any)) {
+      console.error(`Error: Invalid platform "${platform}". Use: ${PLATFORM_PRESET_KEYS.join(", ")}`);
+      process.exit(1);
+    }
+
+    // Read JSON input file
+    const inputPath = resolve(input);
+    if (!existsSync(inputPath)) {
+      console.error(`Error: Input file not found: ${inputPath}`);
+      process.exit(1);
+    }
+    const text = readFileSync(inputPath, "utf-8");
+
+    // Validate it's valid JSON
+    try {
+      JSON.parse(text);
+    } catch {
+      console.error("Error: Input file is not valid JSON");
+      process.exit(1);
+    }
+
+    const config = loadConfig(opts.config);
+
+    const cliDir = dirname(new URL(import.meta.url).pathname);
+    const projectRoot = resolve(cliDir, "../../../../");
+    const templatesEntry = resolve(projectRoot, "packages/templates/src/entry.ts");
+    const assetsRoot = resolve(projectRoot, "assets");
+    const inputDir = opts.assetsDir ? resolve(opts.assetsDir) : dirname(inputPath);
+
+    const noTts = skipTts(opts);
+    const flags: string[] = [];
+    if (noTts) flags.push("no-tts");
+
+    const pipelineConfig: PipelineConfig = {
+      llm: {
+        baseURL: config?.llm?.baseURL,
+        apiKey: config?.llm?.apiKey,
+        model: config?.llm?.model || "gpt-4o",
+      },
+      tts: {
+        provider: opts.ttsProvider || config?.tts?.provider || "openai-tts",
+        voiceId: opts.voice || config?.tts?.[opts.ttsProvider || config?.tts?.provider]?.defaultVoice,
+        format: "mp3",
+        speed: 1.0,
+      },
+      output: {
+        dir: resolve(opts.output || config?.output?.dir || "./output"),
+      },
+      assets: { root: assetsRoot, inputDir },
+      templates: { entryPoint: templatesEntry },
+      skipTts: noTts,
+    };
+
+    const stages = ["parse", "tts", "assets", "compose", "render", "export"];
+
+    console.log(`\nPipeline: generating ${template} video for ${platform}`);
+    console.log(`Input: ${text.length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
+
+    const job = await runPipeline(
+      {
+        text,
+        template: template as any,
+        platform: platform as any,
+        ttsProvider: (opts.ttsProvider || config?.tts?.provider || "openai-tts") as any,
+        voiceId: opts.voice,
+        outputPath: resolve(opts.output || "./output"),
+      },
+      pipelineConfig,
+      {
+        onStageStart: (stage) => {
+          const labels: Record<string, string> = {
+            parse: "Parsing JSON input",
+            tts: noTts ? "Generating silent audio" : "Generating narration",
+            assets: "Resolving assets",
+            compose: "Composing scenes",
+            render: "Rendering video",
+            export: "Exporting",
+          };
+          console.log(`  [${stages.indexOf(stage) + 1}/6] ${labels[stage] || stage}...`);
+        },
+        onStageComplete: (stage) => {
+          console.log(`  [${stages.indexOf(stage) + 1}/6] ${stage} 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");
+          }
+        },
+      }
+    );
+
+    if (job.status === "completed" && job.exported) {
+      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})`);
+      }
+    } else {
+      console.error(`\nFailed: ${job.error}`);
+      process.exit(1);
+    }
+  });
+
+function loadConfig(configPath?: string): Record<string, any> | null {
+  const paths = [
+    configPath,
+    "pipeline.config.yaml",
+    "pipeline.config.yml",
+    "config/default.yaml",
+  ].filter(Boolean) as string[];
+
+  for (const p of paths) {
+    const resolved = resolve(p);
+    if (existsSync(resolved)) {
+      const content = readFileSync(resolved, "utf-8");
+      return parseYaml(content);
+    }
+  }
+  return null;
+}

+ 32 - 0
apps/cli/src/commands/templates.ts

@@ -0,0 +1,32 @@
+import { Command } from "commander";
+import { TEMPLATE_TYPES, PLATFORM_PRESETS } from "@pipeline/shared";
+
+export const templatesCommand = new Command("templates")
+  .description("List available video templates")
+  .action(() => {
+    console.log("\nAvailable Templates:\n");
+    for (const t of TEMPLATE_TYPES) {
+      const names: Record<string, string> = {
+        news: "News Report (资讯报道)",
+        knowledge: "Knowledge Explanation (知识讲解)",
+        opinion: "Opinion Sharing (观点分享)",
+        marketing: "Product Marketing (产品营销)",
+      };
+      const colors: Record<string, string> = {
+        news: "Blue + White + Red",
+        knowledge: "Teal + White + Gray",
+        opinion: "Orange + Dark Gray + White",
+        marketing: "Gradient Purple/Pink + White",
+      };
+      console.log(`  ${t.padEnd(12)} ${names[t]}`);
+      console.log(`  ${"".padEnd(12)} Colors: ${colors[t]}`);
+      console.log(`  ${"".padEnd(12)} Compositions: ${t}-16:9 (${PLATFORM_PRESETS.bilibili.width}x${PLATFORM_PRESETS.bilibili.height}), ${t}-9:16 (${PLATFORM_PRESETS["douyin-long"].width}x${PLATFORM_PRESETS["douyin-long"].height})`);
+      console.log();
+    }
+
+    console.log("Platform Presets:\n");
+    for (const [key, preset] of Object.entries(PLATFORM_PRESETS)) {
+      console.log(`  ${key.padEnd(20)} ${preset.width}x${preset.height} @ ${preset.fps}fps (${preset.aspect})`);
+    }
+    console.log();
+  });

+ 39 - 0
apps/cli/src/commands/voices.ts

@@ -0,0 +1,39 @@
+import { Command } from "commander";
+import { listProviderNames, getProvider } from "@pipeline/tts";
+
+export const voicesCommand = new Command("voices")
+  .description("List available TTS voices")
+  .argument("[provider]", "TTS provider name", "fish-audio")
+  .action(async (providerName: string) => {
+    const providers = listProviderNames();
+    if (!providers.includes(providerName)) {
+      console.error(`Unknown provider: ${providerName}. Available: ${providers.join(", ")}`);
+      process.exit(1);
+    }
+
+    console.log(`\nFetching voices for ${providerName}...\n`);
+
+    try {
+      const provider = getProvider(providerName);
+      const voices = await provider.listVoices();
+
+      if (voices.length === 0) {
+        console.log("No voices found. Set API key and try again.");
+        return;
+      }
+
+      for (const voice of voices) {
+        console.log(`  ${voice.id.padEnd(40)} ${voice.name}`);
+        console.log(`  ${"".padEnd(40)} Language: ${voice.language} | Gender: ${voice.gender || "N/A"}`);
+        if (voice.previewUrl) {
+          console.log(`  ${"".padEnd(40)} Preview: ${voice.previewUrl}`);
+        }
+        console.log();
+      }
+
+      console.log(`Total: ${voices.length} voices`);
+    } catch (err: any) {
+      console.error(`Error: ${err.message}`);
+      console.error("Make sure the required API key is set.");
+    }
+  });

+ 41 - 0
apps/cli/src/index.ts

@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { Command } from "commander";
+import { renderCommand } from "./commands/render.js";
+import { previewCommand } from "./commands/preview.js";
+import { templatesCommand } from "./commands/templates.js";
+import { voicesCommand } from "./commands/voices.js";
+import { initCommand } from "./commands/init.js";
+
+// Auto-load .env from monorepo root (3 levels up from dist/index.js)
+const envPath = resolve(import.meta.dirname, "../../../.env");
+if (existsSync(envPath)) {
+  const content = readFileSync(envPath, "utf-8");
+  for (const line of content.split("\n")) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("#")) continue;
+    const eq = trimmed.indexOf("=");
+    if (eq < 0) continue;
+    const key = trimmed.slice(0, eq).trim();
+    const val = trimmed.slice(eq + 1).trim();
+    if (!process.env[key]) {
+      process.env[key] = val;
+    }
+  }
+}
+
+const program = new Command();
+
+program
+  .name("pipeline")
+  .description("Text-to-video generation pipeline")
+  .version("0.0.1");
+
+program.addCommand(renderCommand);
+program.addCommand(previewCommand);
+program.addCommand(templatesCommand);
+program.addCommand(voicesCommand);
+program.addCommand(initCommand);
+
+program.parse();

+ 15 - 0
apps/cli/tsconfig.json

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

+ 26 - 0
apps/web/next.config.ts

@@ -0,0 +1,26 @@
+import type { NextConfig } from "next";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+// Load .env from monorepo root at build/start time
+const envPath = resolve(import.meta.dirname, "../../.env");
+const envVars: Record<string, string> = {};
+if (existsSync(envPath)) {
+  const content = readFileSync(envPath, "utf-8");
+  for (const line of content.split("\n")) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("#")) continue;
+    const eq = trimmed.indexOf("=");
+    if (eq < 0) continue;
+    const key = trimmed.slice(0, eq).trim();
+    const val = trimmed.slice(eq + 1).trim();
+    envVars[key] = val;
+  }
+}
+
+const nextConfig: NextConfig = {
+  transpilePackages: ["@pipeline/shared", "@pipeline/core", "@pipeline/tts"],
+  env: envVars,
+};
+
+export default nextConfig;

+ 23 - 0
apps/web/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "@pipeline/web",
+  "version": "0.0.1",
+  "private": true,
+  "scripts": {
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start"
+  },
+  "dependencies": {
+    "@pipeline/shared": "workspace:*",
+    "next": "^15.3.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "typescript": "^5.8.0"
+  }
+}

+ 37 - 0
apps/web/src/app/api/download/route.ts

@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { readFileSync, existsSync, statSync } from "node:fs";
+
+export async function GET(request: Request) {
+  const { searchParams } = new URL(request.url);
+  const filePath = searchParams.get("file");
+  const download = searchParams.get("download") === "1";
+
+  if (!filePath) {
+    return NextResponse.json({ error: "Missing file parameter" }, { status: 400 });
+  }
+
+  // Security: only allow files under the project output directory or /tmp/pipeline-jobs
+  if (!filePath.startsWith("/persist/lkatzey/") && !filePath.startsWith("/tmp/pipeline-jobs/")) {
+    return NextResponse.json({ error: "Access denied" }, { status: 403 });
+  }
+
+  if (!existsSync(filePath)) {
+    return NextResponse.json({ error: "File not found" }, { status: 404 });
+  }
+
+  const data = readFileSync(filePath);
+  const filename = filePath.split("/").pop() || "video.mp4";
+
+  const headers: Record<string, string> = {
+    "Content-Type": "video/mp4",
+    "Content-Length": data.length.toString(),
+  };
+
+  if (download) {
+    headers["Content-Disposition"] = `attachment; filename="${filename}"`;
+  } else {
+    headers["Content-Disposition"] = `inline; filename="${filename}"`;
+  }
+
+  return new NextResponse(data, { status: 200, headers });
+}

+ 14 - 0
apps/web/src/app/api/jobs/[id]/route.ts

@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { getJob } from "@/lib/job-store";
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  const { id } = await params;
+  const job = getJob(id);
+  if (!job) {
+    return NextResponse.json({ error: "Job not found" }, { status: 404 });
+  }
+  return NextResponse.json(job);
+}

+ 6 - 0
apps/web/src/app/api/jobs/route.ts

@@ -0,0 +1,6 @@
+import { NextResponse } from "next/server";
+import { getAllJobs } from "@/lib/job-store";
+
+export async function GET() {
+  return NextResponse.json(getAllJobs());
+}

+ 109 - 0
apps/web/src/app/api/render/route.ts

@@ -0,0 +1,109 @@
+import { NextResponse } from "next/server";
+import { createJob, updateJob } from "@/lib/job-store";
+import { spawn } from "node:child_process";
+import { writeFileSync, mkdirSync, statSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { tmpdir } from "node:os";
+
+export async function POST(request: Request) {
+  const body = await request.json();
+  const { input, options } = body as {
+    input: {
+      text: string;
+      template: string;
+      platform: string;
+      ttsProvider: string;
+      voiceId?: string;
+    };
+    options?: {
+      noTts?: boolean;
+    };
+  };
+
+  const job = createJob(input);
+
+  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
+  mkdirSync(jobDir, { recursive: true });
+  const inputFile = join(jobDir, "input.json");
+  writeFileSync(inputFile, input.text, "utf-8");
+
+  const cliArgs = [
+    "render",
+    inputFile,
+    "-t", input.template,
+    "-p", input.platform,
+    "--tts-provider", input.ttsProvider,
+    "--output", resolve("output"),
+  ];
+  if (input.voiceId) {
+    cliArgs.push("--voice", input.voiceId);
+  }
+  if (options?.noTts) {
+    cliArgs.push("--no-tts");
+  }
+
+  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
+  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 },
+  });
+
+  let stdout = "";
+  let stderr = "";
+
+  child.stdout.on("data", (data: Buffer) => {
+    stdout += data.toString();
+  });
+
+  child.stderr.on("data", (data: Buffer) => {
+    stderr += data.toString();
+  });
+
+  child.on("close", (code: number) => {
+    if (code === 0) {
+      const match = stdout.match(/-> (.+\.mp4)/);
+      const filePath = match ? match[1].trim() : "";
+
+      let fileSizeBytes = 0;
+      if (filePath) {
+        try {
+          fileSizeBytes = statSync(filePath).size;
+        } catch {}
+      }
+
+      updateJob(job.id, {
+        status: "completed",
+        outputFiles: filePath
+          ? [
+              {
+                filePath,
+                fileSizeBytes,
+                width: input.platform.includes("9:16") || input.platform.includes("douyin") ? 1080 : 1920,
+                height: input.platform.includes("9:16") || input.platform.includes("douyin") ? 1920 : 1080,
+                durationSeconds: 0,
+              },
+            ]
+          : undefined,
+      });
+    } else {
+      updateJob(job.id, {
+        status: "failed",
+        error: stderr.trim() || `Process exited with code ${code}`,
+      });
+    }
+  });
+
+  child.on("error", (err: Error) => {
+    updateJob(job.id, {
+      status: "failed",
+      error: err.message,
+    });
+  });
+
+  return NextResponse.json({ jobId: job.id, status: "started" });
+}

+ 101 - 0
apps/web/src/app/api/settings/route.ts

@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const ENV_PATH = resolve(process.cwd(), "../../.env");
+
+const SENSITIVE_PREFIXES = ["KEY", "SECRET", "TOKEN", "PASSWORD"];
+
+function isSensitive(key: string): boolean {
+  return SENSITIVE_PREFIXES.some((p) => key.toUpperCase().includes(p));
+}
+
+function maskValue(key: string, value: string): string {
+  if (!value) return "";
+  if (!isSensitive(key)) return value;
+  if (value.length <= 8) return "****";
+  return value.slice(0, 4) + "****" + value.slice(-4);
+}
+
+function parseEnv(content: string): Record<string, string> {
+  const result: Record<string, string> = {};
+  for (const line of content.split("\n")) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("#")) continue;
+    const eq = trimmed.indexOf("=");
+    if (eq < 0) continue;
+    const key = trimmed.slice(0, eq).trim();
+    const val = trimmed.slice(eq + 1).trim();
+    result[key] = val;
+  }
+  return result;
+}
+
+function serializeEnv(vars: Record<string, string>, original: string): string {
+  const originalKeys = new Set<string>();
+  const lines = original.split("\n");
+
+  const updated = lines.map((line) => {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("#")) return line;
+    const eq = trimmed.indexOf("=");
+    if (eq < 0) return line;
+    const key = trimmed.slice(0, eq).trim();
+    originalKeys.add(key);
+    if (key in vars) {
+      return `${key}=${vars[key]}`;
+    }
+    return line;
+  });
+
+  // Append any new keys
+  for (const [key, val] of Object.entries(vars)) {
+    if (!originalKeys.has(key)) {
+      updated.push(`${key}=${val}`);
+    }
+  }
+
+  return updated.join("\n");
+}
+
+export async function GET() {
+  if (!existsSync(ENV_PATH)) {
+    return NextResponse.json({});
+  }
+  const content = readFileSync(ENV_PATH, "utf-8");
+  const parsed = parseEnv(content);
+  const masked: Record<string, string> = {};
+  for (const [key, val] of Object.entries(parsed)) {
+    masked[key] = maskValue(key, val);
+  }
+  return NextResponse.json(masked);
+}
+
+export async function POST(request: Request) {
+  const updates = await request.json() as Record<string, string>;
+
+  let original = "";
+  if (existsSync(ENV_PATH)) {
+    original = readFileSync(ENV_PATH, "utf-8");
+  }
+
+  // Only update keys that are actually present in updates
+  // and have non-masked values (not just "****" patterns)
+  const filtered: Record<string, string> = {};
+  for (const [key, val] of Object.entries(updates)) {
+    if (val && !val.endsWith("****")) {
+      filtered[key] = val;
+    }
+  }
+
+  const serialized = serializeEnv(filtered, original);
+  writeFileSync(ENV_PATH, serialized, "utf-8");
+
+  // Re-read to return masked values
+  const reparsed = parseEnv(serialized);
+  const masked: Record<string, string> = {};
+  for (const [key, val] of Object.entries(reparsed)) {
+    masked[key] = maskValue(key, val);
+  }
+  return NextResponse.json({ ok: true, env: masked });
+}

+ 280 - 0
apps/web/src/app/create/page.tsx

@@ -0,0 +1,280 @@
+"use client";
+
+import { useState } from "react";
+import { TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
+
+const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: string }> = {
+  news: { label: "资讯报道", desc: "Breaking news style with headline cards and ticker", color: "#1a56db" },
+  knowledge: { label: "知识讲解", desc: "Step-by-step explanation with key point cards", color: "#0d9488" },
+  opinion: { label: "观点分享", desc: "Quote-style layout for editorial content", color: "#ea580c" },
+  marketing: { label: "产品营销", desc: "Product showcase with CTA overlays", color: "#9333ea" },
+};
+
+const PLATFORMS_INFO: Record<string, string> = {
+  bilibili: "Bilibili 1920x1080 16:9",
+  "douyin-long": "抖音 竖屏长视频 1080x1920",
+  "douyin-short": "抖音 竖屏短视频 1080x1920",
+  "universal-16x9": "通用横屏 1920x1080",
+  "universal-9x16": "通用竖屏 1080x1920",
+};
+
+const JSON_PLACEHOLDER = `{
+  "title": "标题",
+  "subtitle": "副标题",
+  "cover": {
+    "keyframes": [
+      { "type": "text", "content": "要点预览" }
+    ]
+  },
+  "scenes": [
+    {
+      "id": "scene-1",
+      "narration": "配音文本",
+      "keyframes": [
+        { "type": "text", "content": "要点一" }
+      ]
+    }
+  ],
+  "outro": {
+    "text": "感谢观看",
+    "cta": "点赞 | 关注"
+  }
+}`;
+
+type Step = "input" | "style" | "voice" | "review";
+
+export default function CreatePage() {
+  const [step, setStep] = useState<Step>("input");
+  const [text, setText] = useState("");
+  const [template, setTemplate] = useState("news");
+  const [platform, setPlatform] = useState("bilibili");
+  const [ttsProvider, setTtsProvider] = useState("openai-tts");
+  const [voiceId, setVoiceId] = useState("");
+  const [submitting, setSubmitting] = useState(false);
+  const [jobId, setJobId] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  const steps: { key: Step; label: string }[] = [
+    { key: "input", label: "1. JSON Input" },
+    { key: "style", label: "2. Template" },
+    { key: "voice", label: "3. Voice" },
+    { key: "review", label: "4. Review" },
+  ];
+
+  const stepIndex = steps.findIndex((s) => s.key === step);
+
+  async function handleRender() {
+    setSubmitting(true);
+    setError(null);
+    try {
+      const res = await fetch("/api/render", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          input: { text, template, platform, ttsProvider, voiceId },
+        }),
+      });
+      const data = await res.json();
+      if (data.jobId) {
+        setJobId(data.jobId);
+      } else {
+        setError(data.error || "Unknown error");
+      }
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  if (jobId) {
+    return (
+      <div>
+        <div className="page-header">
+          <h1>Rendering...</h1>
+          <p>Your video is being generated</p>
+        </div>
+        <div className="card" style={{ textAlign: "center", padding: 48 }}>
+          <div className="animate-pulse" style={{ fontSize: 48, marginBottom: 16 }}>&#9881;</div>
+          <p style={{ marginBottom: 16 }}>Job ID: <code>{jobId}</code></p>
+          <a href={`/jobs/${jobId}`}>
+            <button className="btn btn-primary">View Job Status</button>
+          </a>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div className="page-header">
+        <h1>Create Video</h1>
+        <p>Provide structured JSON to generate a video</p>
+      </div>
+
+      <div className="steps">
+        {steps.map((s, i) => (
+          <div
+            key={s.key}
+            className={`step ${s.key === step ? "step-active" : ""} ${i < stepIndex ? "step-done" : ""}`}
+          >
+            {s.label}
+          </div>
+        ))}
+      </div>
+
+      <div className="card" style={{ padding: 32 }}>
+        {step === "input" && (
+          <div>
+            <div className="form-group">
+              <label>Video JSON</label>
+              <textarea
+                className="form-textarea"
+                value={text}
+                onChange={(e) => setText(e.target.value)}
+                placeholder={JSON_PLACEHOLDER}
+                style={{ minHeight: 300, fontFamily: "monospace", fontSize: 13 }}
+              />
+            </div>
+            <button
+              className="btn btn-primary"
+              disabled={!text.trim()}
+              onClick={() => setStep("style")}
+            >
+              Next
+            </button>
+          </div>
+        )}
+
+        {step === "style" && (
+          <div>
+            <div className="form-group">
+              <label>Template</label>
+              <div className="card-grid">
+                {TEMPLATE_TYPES.map((t) => (
+                  <div
+                    key={t}
+                    className={`card card-hover template-card ${template === t ? "selected" : ""}`}
+                    style={template === t ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
+                    onClick={() => setTemplate(t)}
+                  >
+                    <div
+                      className="template-color-bar"
+                      style={{ background: TEMPLATES_INFO[t].color }}
+                    />
+                    <h3>{TEMPLATES_INFO[t].label}</h3>
+                    <p>{TEMPLATES_INFO[t].desc}</p>
+                  </div>
+                ))}
+              </div>
+            </div>
+
+            <div className="form-group">
+              <label>Platform</label>
+              <select
+                className="form-select"
+                value={platform}
+                onChange={(e) => setPlatform(e.target.value)}
+              >
+                {PLATFORM_PRESET_KEYS.map((p) => (
+                  <option key={p} value={p}>{PLATFORMS_INFO[p]}</option>
+                ))}
+              </select>
+            </div>
+
+            <div style={{ display: "flex", gap: 12 }}>
+              <button className="btn btn-secondary" onClick={() => setStep("input")}>
+                Back
+              </button>
+              <button className="btn btn-primary" onClick={() => setStep("voice")}>
+                Next
+              </button>
+            </div>
+          </div>
+        )}
+
+        {step === "voice" && (
+          <div>
+            <div className="form-group">
+              <label>TTS Provider</label>
+              <select
+                className="form-select"
+                value={ttsProvider}
+                onChange={(e) => setTtsProvider(e.target.value)}
+              >
+                <option value="openai-tts">OpenAI TTS (Seed TTS)</option>
+                <option value="fish-audio">Fish Audio</option>
+                <option value="minimax">MiniMax</option>
+                <option value="elevenlabs">ElevenLabs</option>
+              </select>
+            </div>
+            <div className="form-group">
+              <label>Voice ID (optional)</label>
+              <input
+                className="form-input"
+                value={voiceId}
+                onChange={(e) => setVoiceId(e.target.value)}
+                placeholder="Leave empty for default voice"
+              />
+            </div>
+            <div style={{ display: "flex", gap: 12 }}>
+              <button className="btn btn-secondary" onClick={() => setStep("style")}>
+                Back
+              </button>
+              <button className="btn btn-primary" onClick={() => setStep("review")}>
+                Next
+              </button>
+            </div>
+          </div>
+        )}
+
+        {step === "review" && (
+          <div>
+            <h3 style={{ marginBottom: 20 }}>Review Configuration</h3>
+            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
+              <div className="card">
+                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
+                <div style={{ fontWeight: 600 }}>{TEMPLATES_INFO[template]?.label} ({template})</div>
+              </div>
+              <div className="card">
+                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
+                <div style={{ fontWeight: 600 }}>{platform}</div>
+              </div>
+              <div className="card">
+                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
+                <div style={{ fontWeight: 600 }}>{ttsProvider}</div>
+              </div>
+              <div className="card">
+                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input Size</div>
+                <div style={{ fontWeight: 600 }}>{text.length} chars</div>
+              </div>
+            </div>
+            <div className="card" style={{ marginBottom: 24 }}>
+              <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>JSON Preview</div>
+              <pre style={{ fontSize: 13, color: "var(--text-muted)", maxHeight: 120, overflow: "hidden" }}>
+                {text.slice(0, 500)}{text.length > 500 ? "\n..." : ""}
+              </pre>
+            </div>
+
+            {error && (
+              <div style={{ color: "var(--error)", marginBottom: 16 }}>{error}</div>
+            )}
+
+            <div style={{ display: "flex", gap: 12 }}>
+              <button className="btn btn-secondary" onClick={() => setStep("voice")}>
+                Back
+              </button>
+              <button
+                className="btn btn-primary"
+                disabled={submitting}
+                onClick={handleRender}
+              >
+                {submitting ? "Starting..." : "Generate Video"}
+              </button>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 298 - 0
apps/web/src/app/globals.css

@@ -0,0 +1,298 @@
+:root {
+  --bg: #0a0a0a;
+  --bg-card: #141414;
+  --bg-hover: #1a1a1a;
+  --border: #262626;
+  --text: #fafafa;
+  --text-muted: #a1a1aa;
+  --primary: #3b82f6;
+  --primary-hover: #2563eb;
+  --accent: #fbbf24;
+  --success: #22c55e;
+  --error: #ef4444;
+  --warning: #f59e0b;
+}
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  line-height: 1.5;
+}
+
+a {
+  color: var(--primary);
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+}
+
+/* Layout */
+.app-layout {
+  display: flex;
+  min-height: 100vh;
+}
+
+.sidebar {
+  width: 240px;
+  background: var(--bg-card);
+  border-right: 1px solid var(--border);
+  padding: 24px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  position: fixed;
+  top: 0;
+  left: 0;
+  bottom: 0;
+}
+
+.sidebar-brand {
+  font-size: 20px;
+  font-weight: 700;
+  color: var(--text);
+  padding: 8px 12px 24px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.sidebar-brand-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 5px;
+  background: var(--primary);
+}
+
+.sidebar a {
+  display: block;
+  padding: 10px 12px;
+  border-radius: 8px;
+  color: var(--text-muted);
+  font-size: 14px;
+  transition: all 0.15s;
+  text-decoration: none;
+}
+
+.sidebar a:hover {
+  background: var(--bg-hover);
+  color: var(--text);
+  text-decoration: none;
+}
+
+.main-content {
+  margin-left: 240px;
+  flex: 1;
+  padding: 32px 48px;
+  max-width: 1200px;
+}
+
+/* Page header */
+.page-header {
+  margin-bottom: 32px;
+}
+
+.page-header h1 {
+  font-size: 28px;
+  font-weight: 700;
+  margin-bottom: 8px;
+}
+
+.page-header p {
+  color: var(--text-muted);
+  font-size: 15px;
+}
+
+/* Cards */
+.card {
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 24px;
+}
+
+.card-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 16px;
+}
+
+.card-hover {
+  transition: border-color 0.15s, box-shadow 0.15s;
+  cursor: pointer;
+}
+
+.card-hover:hover {
+  border-color: var(--primary);
+  box-shadow: 0 0 0 1px var(--primary);
+}
+
+/* Buttons */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 20px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  border: none;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.btn-primary {
+  background: var(--primary);
+  color: white;
+}
+
+.btn-primary:hover {
+  background: var(--primary-hover);
+}
+
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.btn-secondary {
+  background: var(--bg-hover);
+  color: var(--text);
+  border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+  background: var(--border);
+}
+
+/* Form elements */
+.form-group {
+  margin-bottom: 20px;
+}
+
+.form-group label {
+  display: block;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-muted);
+  margin-bottom: 6px;
+}
+
+.form-input,
+.form-select,
+.form-textarea {
+  width: 100%;
+  padding: 10px 14px;
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  color: var(--text);
+  font-size: 14px;
+  font-family: inherit;
+  transition: border-color 0.15s;
+}
+
+.form-input:focus,
+.form-select:focus,
+.form-textarea:focus {
+  outline: none;
+  border-color: var(--primary);
+}
+
+.form-textarea {
+  min-height: 200px;
+  resize: vertical;
+}
+
+/* Badge */
+.badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 10px;
+  border-radius: 20px;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.badge-pending { background: #262626; color: #a1a1aa; }
+.badge-running { background: #1e3a5f; color: #60a5fa; }
+.badge-completed { background: #14532d; color: #4ade80; }
+.badge-failed { background: #450a0a; color: #f87171; }
+
+/* Steps */
+.steps {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 32px;
+}
+
+.step {
+  flex: 1;
+  padding: 12px 16px;
+  border-radius: 8px;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  text-align: center;
+  font-size: 13px;
+  color: var(--text-muted);
+}
+
+.step-active {
+  border-color: var(--primary);
+  color: var(--text);
+}
+
+.step-done {
+  border-color: var(--success);
+  color: var(--success);
+}
+
+/* Template cards */
+.template-card {
+  padding: 20px;
+}
+
+.template-card h3 {
+  font-size: 16px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.template-color-bar {
+  height: 4px;
+  border-radius: 2px;
+  margin-bottom: 12px;
+}
+
+.template-card p {
+  font-size: 13px;
+  color: var(--text-muted);
+  margin-bottom: 12px;
+}
+
+/* Status */
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 4px;
+  display: inline-block;
+  margin-right: 6px;
+}
+
+/* Animations */
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.5; }
+}
+
+.animate-pulse {
+  animation: pulse 2s infinite;
+}

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

@@ -0,0 +1,182 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+interface OutputFile {
+  filePath: string;
+  fileSizeBytes: number;
+  width: number;
+  height: number;
+  durationSeconds: number;
+}
+
+interface Job {
+  id: string;
+  status: "pending" | "running" | "completed" | "failed";
+  input: {
+    text: string;
+    template: string;
+    platform: string;
+    ttsProvider: string;
+  };
+  createdAt: number;
+  updatedAt: number;
+  error?: string;
+  outputFiles?: OutputFile[];
+}
+
+const STATUS_BADGE: Record<string, string> = {
+  pending: "badge-pending",
+  running: "badge-running",
+  completed: "badge-completed",
+  failed: "badge-failed",
+};
+
+const STATUS_LABEL: Record<string, string> = {
+  pending: "Waiting",
+  running: "Rendering...",
+  completed: "Completed",
+  failed: "Failed",
+};
+
+export default function JobDetailPage({ params }: { params: Promise<{ id: string }> }) {
+  const [job, setJob] = useState<Job | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [id, setId] = useState<string>("");
+
+  useEffect(() => {
+    params.then((p) => setId(p.id));
+  }, [params]);
+
+  useEffect(() => {
+    if (!id) return;
+    const load = () => {
+      fetch(`/api/jobs/${id}`)
+        .then((r) => r.json())
+        .then((data: Job) => {
+          setJob(data);
+          setLoading(false);
+          if (data.status === "pending" || data.status === "running") {
+            setTimeout(load, 2000);
+          }
+        })
+        .catch(() => setLoading(false));
+    };
+    load();
+  }, [id]);
+
+  if (loading) {
+    return (
+      <div style={{ padding: 48, textAlign: "center", color: "var(--text-muted)" }}>
+        Loading...
+      </div>
+    );
+  }
+
+  if (!job) {
+    return (
+      <div style={{ padding: 48, textAlign: "center" }}>
+        <p>Job not found</p>
+        <Link href="/jobs"><button className="btn btn-secondary" style={{ marginTop: 16 }}>Back to Jobs</button></Link>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div className="page-header">
+        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+          <Link href="/jobs" style={{ color: "var(--text-muted)", fontSize: 14 }}>Jobs</Link>
+          <span style={{ color: "var(--text-muted)" }}>/</span>
+          <h1 style={{ fontSize: 22 }}>{job.id.slice(0, 8)}</h1>
+        </div>
+        <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
+          <span className={`badge ${STATUS_BADGE[job.status]}`}>
+            {job.status === "running" && (
+              <span className="status-dot animate-pulse" style={{ background: "#60a5fa" }} />
+            )}
+            {STATUS_LABEL[job.status]}
+          </span>
+          <span style={{ fontSize: 13, color: "var(--text-muted)" }}>
+            {new Date(job.createdAt).toLocaleString("zh-CN")}
+          </span>
+        </div>
+      </div>
+
+      {/* Config cards */}
+      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
+        <div className="card">
+          <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
+          <div style={{ fontWeight: 600 }}>{job.input.template}</div>
+        </div>
+        <div className="card">
+          <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
+          <div style={{ fontWeight: 600 }}>{job.input.platform}</div>
+        </div>
+        <div className="card">
+          <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
+          <div style={{ fontWeight: 600 }}>{job.input.ttsProvider}</div>
+        </div>
+        <div className="card">
+          <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
+          <div style={{ fontWeight: 600 }}>JSON ({job.input.text.length} chars)</div>
+        </div>
+      </div>
+
+      {/* Error */}
+      {job.error && (
+        <div className="card" style={{ borderColor: "var(--error)", marginBottom: 24 }}>
+          <div style={{ color: "var(--error)", fontWeight: 600, marginBottom: 4 }}>Error</div>
+          <pre style={{ fontSize: 13, whiteSpace: "pre-wrap", color: "var(--error)" }}>{job.error}</pre>
+        </div>
+      )}
+
+      {/* Output files */}
+      {job.status === "completed" && job.outputFiles && job.outputFiles.length > 0 && (
+        <div className="card" style={{ marginBottom: 24 }}>
+          <h3 style={{ marginBottom: 16 }}>Output</h3>
+          {job.outputFiles.map((file, i) => (
+            <div key={i}>
+              {/* Video player */}
+              <video
+                controls
+                style={{
+                  width: "100%",
+                  maxHeight: 400,
+                  borderRadius: 8,
+                  backgroundColor: "#000",
+                  marginBottom: 12,
+                }}
+                src={`/api/download?file=${encodeURIComponent(file.filePath)}`}
+              />
+              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
+                <div>
+                  <div style={{ fontWeight: 500, marginBottom: 4 }}>{file.filePath.split("/").pop()}</div>
+                  <div style={{ fontSize: 13, color: "var(--text-muted)" }}>
+                    {file.width}x{file.height} | {(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB
+                  </div>
+                </div>
+                <a
+                  href={`/api/download?file=${encodeURIComponent(file.filePath)}&download=1`}
+                  className="btn btn-primary"
+                  style={{ fontSize: 13 }}
+                >
+                  Download
+                </a>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Input text */}
+      <div className="card">
+        <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>Input Text</div>
+        <pre style={{ fontSize: 13, color: "var(--text-muted)", whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
+          {job.input.text.slice(0, 1000)}{job.input.text.length > 1000 ? "..." : ""}
+        </pre>
+      </div>
+    </div>
+  );
+}

+ 85 - 0
apps/web/src/app/jobs/page.tsx

@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+interface Job {
+  id: string;
+  status: "pending" | "running" | "completed" | "failed";
+  input: { template: string; platform: string };
+  createdAt: number;
+  updatedAt: number;
+  error?: string;
+}
+
+const STATUS_BADGE: Record<string, string> = {
+  pending: "badge-pending",
+  running: "badge-running",
+  completed: "badge-completed",
+  failed: "badge-failed",
+};
+
+export default function JobsPage() {
+  const [jobs, setJobs] = useState<Job[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    fetch("/api/jobs")
+      .then((r) => r.json())
+      .then((data) => {
+        setJobs(data);
+        setLoading(false);
+      })
+      .catch(() => setLoading(false));
+  }, []);
+
+  return (
+    <div>
+      <div className="page-header">
+        <h1>Jobs</h1>
+        <p>Video generation jobs</p>
+      </div>
+
+      {loading ? (
+        <div style={{ color: "var(--text-muted)", padding: 32, textAlign: "center" }}>
+          Loading...
+        </div>
+      ) : jobs.length === 0 ? (
+        <div className="card" style={{ textAlign: "center", padding: 48 }}>
+          <p style={{ color: "var(--text-muted)", marginBottom: 16 }}>No jobs yet</p>
+          <Link href="/create">
+            <button className="btn btn-primary">Create your first video</button>
+          </Link>
+        </div>
+      ) : (
+        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
+          {jobs.map((job) => (
+            <Link key={job.id} href={`/jobs/${job.id}`} style={{ textDecoration: "none" }}>
+              <div className="card card-hover" style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
+                <div>
+                  <div style={{ fontWeight: 600, marginBottom: 4 }}>
+                    {job.input.template} / {job.input.platform}
+                  </div>
+                  <div style={{ fontSize: 13, color: "var(--text-muted)" }}>
+                    {new Date(job.createdAt).toLocaleString("zh-CN")}
+                  </div>
+                </div>
+                <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+                  <span className={`badge ${STATUS_BADGE[job.status]}`}>
+                    {job.status === "running" && (
+                      <span className="status-dot animate-pulse" style={{ background: "#60a5fa" }} />
+                    )}
+                    {job.status}
+                  </span>
+                  <span style={{ fontSize: 13, color: "var(--text-muted)" }}>
+                    {job.id.slice(0, 8)}
+                  </span>
+                </div>
+              </div>
+            </Link>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 34 - 0
apps/web/src/app/layout.tsx

@@ -0,0 +1,34 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "Pipeline — Text to Video",
+  description: "Convert text content into polished videos with AI-powered narration and visual effects",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="zh-CN">
+      <body>
+        <div className="app-layout">
+          <nav className="sidebar">
+            <div className="sidebar-brand">
+              <span className="sidebar-brand-dot" />
+              Pipeline
+            </div>
+            <a href="/">Dashboard</a>
+            <a href="/create">Create Video</a>
+            <a href="/jobs">Jobs</a>
+            <a href="/templates">Templates</a>
+            <a href="/settings">Settings</a>
+          </nav>
+          <main className="main-content">{children}</main>
+        </div>
+      </body>
+    </html>
+  );
+}

+ 58 - 0
apps/web/src/app/page.tsx

@@ -0,0 +1,58 @@
+import Link from "next/link";
+
+export default function DashboardPage() {
+  return (
+    <div>
+      <div className="page-header">
+        <h1>Dashboard</h1>
+        <p>Generate videos from text content with AI narration</p>
+      </div>
+
+      <div style={{ display: "flex", gap: 16, marginBottom: 32 }}>
+        <Link href="/create">
+          <button className="btn btn-primary">+ Create Video</button>
+        </Link>
+      </div>
+
+      <div className="card-grid">
+        <div className="card card-hover" style={{ padding: 32, textAlign: "center" }}>
+          <div style={{ fontSize: 48, marginBottom: 12 }}>&#9889;</div>
+          <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Quick Start</h3>
+          <p style={{ color: "var(--text-muted)", fontSize: 14 }}>
+            Paste text or upload a Markdown file to generate a video in minutes
+          </p>
+        </div>
+
+        <Link href="/templates" style={{ textDecoration: "none" }}>
+          <div className="card card-hover" style={{ padding: 32, textAlign: "center" }}>
+            <div style={{ fontSize: 48, marginBottom: 12 }}>&#127912;</div>
+            <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Templates</h3>
+            <p style={{ color: "var(--text-muted)", fontSize: 14 }}>
+              4 templates: News, Knowledge, Opinion, Marketing
+            </p>
+          </div>
+        </Link>
+
+        <Link href="/jobs" style={{ textDecoration: "none" }}>
+          <div className="card card-hover" style={{ padding: 32, textAlign: "center" }}>
+            <div style={{ fontSize: 48, marginBottom: 12 }}>&#128249;</div>
+            <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Jobs</h3>
+            <p style={{ color: "var(--text-muted)", fontSize: 14 }}>
+              View and manage your video generation jobs
+            </p>
+          </div>
+        </Link>
+
+        <Link href="/settings" style={{ textDecoration: "none" }}>
+          <div className="card card-hover" style={{ padding: 32, textAlign: "center" }}>
+            <div style={{ fontSize: 48, marginBottom: 12 }}>&#9881;</div>
+            <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Settings</h3>
+            <p style={{ color: "var(--text-muted)", fontSize: 14 }}>
+              Configure API keys and default preferences
+            </p>
+          </div>
+        </Link>
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,203 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+interface EnvVars {
+  OPENAI_API_KEY: string;
+  OPENAI_BASE_URL: string;
+  OPENAI_TTS_API_KEY: string;
+  OPENAI_TTS_BASE_URL: string;
+  OPENAI_TTS_MODEL: string;
+  FISH_AUDIO_API_KEY: string;
+  MINIMAX_API_KEY: string;
+  MINIMAX_GROUP_ID: string;
+  ELEVENLABS_API_KEY: string;
+}
+
+const DEFAULT_ENV: EnvVars = {
+  OPENAI_API_KEY: "",
+  OPENAI_BASE_URL: "https://api.openai.com/v1",
+  OPENAI_TTS_API_KEY: "",
+  OPENAI_TTS_BASE_URL: "https://new-api.corp.shuidi.tech/v1",
+  OPENAI_TTS_MODEL: "seed-tts-1.1",
+  FISH_AUDIO_API_KEY: "",
+  MINIMAX_API_KEY: "",
+  MINIMAX_GROUP_ID: "",
+  ELEVENLABS_API_KEY: "",
+};
+
+export default function SettingsPage() {
+  const [keys, setKeys] = useState<EnvVars>(DEFAULT_ENV);
+  const [loaded, setLoaded] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [saved, setSaved] = useState(false);
+
+  useEffect(() => {
+    fetch("/api/settings")
+      .then((r) => r.json())
+      .then((data) => {
+        setKeys((prev) => ({ ...prev, ...data }));
+        setLoaded(true);
+      })
+      .catch(() => setLoaded(true));
+  }, []);
+
+  async function handleSave() {
+    setSaving(true);
+    try {
+      await fetch("/api/settings", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(keys),
+      });
+      setSaved(true);
+      setTimeout(() => setSaved(false), 2000);
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  if (!loaded) {
+    return (
+      <div>
+        <div className="page-header">
+          <h1>Settings</h1>
+        </div>
+        <div className="card" style={{ padding: 32, textAlign: "center" }}>
+          Loading...
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div className="page-header">
+        <h1>Settings</h1>
+        <p>Configure API keys and service endpoints (saved to .env)</p>
+      </div>
+
+      <div className="card" style={{ marginBottom: 24 }}>
+        <h3 style={{ marginBottom: 20 }}>LLM Configuration</h3>
+        <div className="form-group">
+          <label>OpenAI API Key</label>
+          <input
+            className="form-input"
+            type="password"
+            value={keys.OPENAI_API_KEY}
+            onChange={(e) => setKeys({ ...keys, OPENAI_API_KEY: e.target.value })}
+            placeholder="sk-..."
+          />
+        </div>
+        <div className="form-group">
+          <label>Base URL</label>
+          <input
+            className="form-input"
+            value={keys.OPENAI_BASE_URL}
+            onChange={(e) => setKeys({ ...keys, OPENAI_BASE_URL: e.target.value })}
+            placeholder="https://api.openai.com/v1"
+          />
+          <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}>
+            OpenAI-compatible endpoints (DeepSeek, Qwen, etc.)
+          </div>
+        </div>
+      </div>
+
+      <div className="card" style={{ marginBottom: 24 }}>
+        <h3 style={{ marginBottom: 20 }}>TTS Providers</h3>
+
+        <div style={{ marginBottom: 20, paddingBottom: 20, borderBottom: "1px solid var(--border)" }}>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#3b82f6" }}>
+            OpenAI TTS (Seed TTS) — Default
+          </div>
+          <div className="form-group">
+            <label>API Key</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.OPENAI_TTS_API_KEY}
+              onChange={(e) => setKeys({ ...keys, OPENAI_TTS_API_KEY: e.target.value })}
+            />
+          </div>
+          <div className="form-group">
+            <label>Base URL</label>
+            <input
+              className="form-input"
+              value={keys.OPENAI_TTS_BASE_URL}
+              onChange={(e) => setKeys({ ...keys, OPENAI_TTS_BASE_URL: e.target.value })}
+            />
+          </div>
+          <div className="form-group">
+            <label>Model</label>
+            <input
+              className="form-input"
+              value={keys.OPENAI_TTS_MODEL}
+              onChange={(e) => setKeys({ ...keys, OPENAI_TTS_MODEL: e.target.value })}
+              placeholder="seed-tts-1.1"
+            />
+          </div>
+        </div>
+
+        <div style={{ marginBottom: 20, paddingBottom: 20, borderBottom: "1px solid var(--border)" }}>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#10b981" }}>
+            Fish Audio
+          </div>
+          <div className="form-group">
+            <label>API Key</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.FISH_AUDIO_API_KEY}
+              onChange={(e) => setKeys({ ...keys, FISH_AUDIO_API_KEY: e.target.value })}
+            />
+          </div>
+        </div>
+
+        <div style={{ marginBottom: 20, paddingBottom: 20, borderBottom: "1px solid var(--border)" }}>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#0d9488" }}>
+            MiniMax
+          </div>
+          <div className="form-group">
+            <label>API Key</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.MINIMAX_API_KEY}
+              onChange={(e) => setKeys({ ...keys, MINIMAX_API_KEY: e.target.value })}
+            />
+          </div>
+          <div className="form-group">
+            <label>Group ID</label>
+            <input
+              className="form-input"
+              value={keys.MINIMAX_GROUP_ID}
+              onChange={(e) => setKeys({ ...keys, MINIMAX_GROUP_ID: e.target.value })}
+            />
+          </div>
+        </div>
+
+        <div>
+          <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#9333ea" }}>
+            ElevenLabs
+          </div>
+          <div className="form-group">
+            <label>API Key</label>
+            <input
+              className="form-input"
+              type="password"
+              value={keys.ELEVENLABS_API_KEY}
+              onChange={(e) => setKeys({ ...keys, ELEVENLABS_API_KEY: e.target.value })}
+            />
+          </div>
+        </div>
+      </div>
+
+      <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
+        <button className="btn btn-primary" onClick={handleSave} disabled={saving}>
+          {saving ? "Saving..." : "Save to .env"}
+        </button>
+        {saved && <span style={{ color: "var(--success)", fontSize: 14 }}>Saved! Restart services to apply.</span>}
+      </div>
+    </div>
+  );
+}

+ 74 - 0
apps/web/src/app/templates/page.tsx

@@ -0,0 +1,74 @@
+import { TEMPLATE_TYPES } from "@pipeline/shared";
+
+const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: string; layouts: string }> = {
+  news: {
+    label: "资讯报道",
+    desc: "Breaking news style with headline cards and scrolling ticker. Best for current events and news summaries.",
+    color: "#1a56db",
+    layouts: "Headline bar, split screen, news ticker",
+  },
+  knowledge: {
+    label: "知识讲解",
+    desc: "Educational style with step indicators and key point cards. Ideal for tutorials and explainer videos.",
+    color: "#0d9488",
+    layouts: "Step progress, centered content, bullet cards",
+  },
+  opinion: {
+    label: "观点分享",
+    desc: "Editorial style with typewriter quote blocks. Great for commentary and personal takes.",
+    color: "#ea580c",
+    layouts: "Quote block, author card, centered narrative",
+  },
+  marketing: {
+    label: "产品营销",
+    desc: "Marketing style with product showcases and CTA overlays. Perfect for product launches and promotions.",
+    color: "#9333ea",
+    layouts: "Feature grid, CTA buttons, product showcase",
+  },
+};
+
+export default function TemplatesPage() {
+  return (
+    <div>
+      <div className="page-header">
+        <h1>Templates</h1>
+        <p>Choose from 4 professionally designed video templates, each with 16:9 and 9:16 variants</p>
+      </div>
+
+      <div className="card-grid">
+        {TEMPLATE_TYPES.map((t) => {
+          const info = TEMPLATES_INFO[t];
+          return (
+            <div key={t} className="card" style={{ overflow: "hidden" }}>
+              {/* Color preview bar */}
+              <div
+                style={{
+                  height: 120,
+                  background: `linear-gradient(135deg, ${info.color}33, ${info.color}11)`,
+                  display: "flex",
+                  alignItems: "center",
+                  justifyContent: "center",
+                  borderBottom: `3px solid ${info.color}`,
+                }}
+              >
+                <div style={{ fontSize: 32, fontWeight: 800, color: info.color, fontFamily: "sans-serif" }}>
+                  {info.label}
+                </div>
+              </div>
+              <div style={{ padding: 20 }}>
+                <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{info.label}</h3>
+                <p style={{ fontSize: 14, color: "var(--text-muted)", marginBottom: 12 }}>
+                  {info.desc}
+                </p>
+                <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
+                  <div style={{ marginBottom: 4 }}>Layouts: {info.layouts}</div>
+                  <div>Variants: {t}-16:9 (1920x1080) / {t}-9:16 (1080x1920)</div>
+                </div>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 13 - 0
apps/web/src/lib/api-client.ts

@@ -0,0 +1,13 @@
+export const API_BASE = "";
+
+export async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
+  const res = await fetch(url, {
+    headers: { "Content-Type": "application/json" },
+    ...options,
+  });
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(`API error: ${res.status} ${text}`);
+  }
+  return res.json();
+}

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

@@ -0,0 +1,88 @@
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+export interface JobData {
+  id: string;
+  status: "pending" | "running" | "completed" | "failed";
+  input: {
+    text: string;
+    template: string;
+    platform: string;
+    ttsProvider: string;
+    voiceId?: string;
+  };
+  error?: string;
+  outputFiles?: Array<{
+    filePath: string;
+    fileSizeBytes: number;
+    width: number;
+    height: number;
+    durationSeconds: number;
+  }>;
+  createdAt: number;
+  updatedAt: number;
+}
+
+const STORE_DIR = join(tmpdir(), "pipeline-jobs");
+const STORE_FILE = join(STORE_DIR, "jobs.json");
+
+function ensureStore(): Map<string, JobData> {
+  if (!existsSync(STORE_DIR)) {
+    mkdirSync(STORE_DIR, { recursive: true });
+  }
+  if (!existsSync(STORE_FILE)) {
+    writeFileSync(STORE_FILE, "{}", "utf-8");
+  }
+  try {
+    const raw = readFileSync(STORE_FILE, "utf-8");
+    const obj = JSON.parse(raw) as Record<string, JobData>;
+    return new Map(Object.entries(obj));
+  } catch {
+    return new Map();
+  }
+}
+
+function flushStore(jobs: Map<string, JobData>): void {
+  const obj: Record<string, JobData> = {};
+  for (const [k, v] of jobs) {
+    obj[k] = v;
+  }
+  writeFileSync(STORE_FILE, JSON.stringify(obj), "utf-8");
+}
+
+export function getAllJobs(): JobData[] {
+  const jobs = ensureStore();
+  return Array.from(jobs.values()).sort(
+    (a, b) => b.createdAt - a.createdAt
+  );
+}
+
+export function getJob(id: string): JobData | undefined {
+  const jobs = ensureStore();
+  return jobs.get(id);
+}
+
+export function createJob(input: JobData["input"]): JobData {
+  const jobs = ensureStore();
+  const now = Date.now();
+  const job: JobData = {
+    id: crypto.randomUUID(),
+    status: "pending",
+    input,
+    createdAt: now,
+    updatedAt: now,
+  };
+  jobs.set(job.id, job);
+  flushStore(jobs);
+  return job;
+}
+
+export function updateJob(id: string, updates: Partial<JobData>): JobData | undefined {
+  const jobs = ensureStore();
+  const job = jobs.get(id);
+  if (!job) return undefined;
+  Object.assign(job, updates, { updatedAt: Date.now() });
+  flushStore(jobs);
+  return job;
+}

+ 40 - 0
apps/web/tsconfig.json

@@ -0,0 +1,40 @@
+{
+  "compilerOptions": {
+    "jsx": "preserve",
+    "lib": [
+      "ES2022",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "target": "ES2022",
+    "strict": true,
+    "allowJs": true,
+    "incremental": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "paths": {
+      "@/*": [
+        "./src/*"
+      ]
+    },
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "noEmit": true,
+    "isolatedModules": true,
+    "resolveJsonModule": true
+  },
+  "include": [
+    "next-env.d.ts",
+    "src",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

二進制
assets/backgrounds/default-knowledge-16x9.png


二進制
assets/backgrounds/default-knowledge-9x16.png


二進制
assets/backgrounds/default-marketing-16x9.png


二進制
assets/backgrounds/default-marketing-9x16.png


二進制
assets/backgrounds/default-news-16x9.png


二進制
assets/backgrounds/default-news-9x16.png


二進制
assets/backgrounds/default-opinion-16x9.png


二進制
assets/backgrounds/default-opinion-9x16.png


二進制
assets/backgrounds/default.png


二進制
assets/fonts/NotoSansSC-Bold.ttf


二進制
assets/fonts/NotoSansSC-Regular.ttf


+ 47 - 0
config/default.yaml

@@ -0,0 +1,47 @@
+# Pipeline default configuration
+
+llm:
+  baseURL: "https://api.openai.com/v1"
+  model: "gpt-4o"
+  # apiKey: set via OPENAI_API_KEY env var
+
+tts:
+  provider: "openai-tts"
+  openai-tts:
+    # apiKey: set via OPENAI_TTS_API_KEY env var
+    # baseURL: defaults to https://new-api.corp.shuidi.tech/v1
+    model: "seed-tts-1.1"
+    defaultVoice: "zh_female_vv_uranus_bigtts"
+  fish-audio:
+    # apiKey: set via FISH_AUDIO_API_KEY env var
+    model: "s2-pro"
+    defaultVoice: ""
+  minimax:
+    # apiKey: set via MINIMAX_API_KEY env var
+    groupId: ""
+    model: "speech-02-hd"
+  elevenlabs:
+    # apiKey: set via ELEVENLABS_API_KEY env var
+    model: "eleven_multilingual_v2"
+
+render:
+  fps: 30
+  codec: "h264"
+
+output:
+  dir: "./output"
+  format: "mp4"
+
+templates:
+  news:
+    primaryColor: "#1a56db"
+    accentColor: "#dc2626"
+  knowledge:
+    primaryColor: "#0d9488"
+    accentColor: "#6b7280"
+  opinion:
+    primaryColor: "#ea580c"
+    accentColor: "#374151"
+  marketing:
+    primaryColor: "#9333ea"
+    accentColor: "#ec4899"

+ 18 - 0
package.json

@@ -0,0 +1,18 @@
+{
+  "name": "pipeline",
+  "private": true,
+  "scripts": {
+    "build": "turbo build",
+    "dev": "turbo dev",
+    "lint": "turbo lint",
+    "typecheck": "turbo typecheck"
+  },
+  "devDependencies": {
+    "turbo": "^2.5.0",
+    "typescript": "^5.8.0"
+  },
+  "packageManager": "pnpm@10.24.0",
+  "pnpm": {
+    "onlyBuiltDependencies": ["esbuild"]
+  }
+}

+ 30 - 0
packages/core/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@pipeline/core",
+  "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:*",
+    "@pipeline/templates": "workspace:*",
+    "@remotion/renderer": "^4.0.0",
+    "@remotion/bundler": "^4.0.0",
+    "zod": "^3.24.0",
+    "tmp-promise": "^3.0.3"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

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

@@ -0,0 +1,2 @@
+export { runPipeline } from "./pipeline.js";
+export type { PipelineConfig, PipelineCallbacks } from "./pipeline.js";

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

@@ -0,0 +1,126 @@
+import { randomUUID } from "node:crypto";
+import { mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import type {
+  PipelineInput,
+  PipelineJob,
+} from "@pipeline/shared";
+import { PLATFORM_PRESETS } from "@pipeline/shared";
+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 } from "./stages/export.js";
+
+export interface PipelineConfig {
+  llm: {
+    baseURL?: string;
+    apiKey?: string;
+    model: string;
+  };
+  tts: {
+    provider: string;
+    voiceId?: string;
+    format?: "mp3" | "wav" | "pcm";
+    speed?: number;
+  };
+  output: {
+    dir: string;
+  };
+  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;
+}
+
+export async function runPipeline(
+  input: PipelineInput,
+  config: PipelineConfig,
+  callbacks?: PipelineCallbacks
+): Promise<PipelineJob> {
+  const jobId = randomUUID();
+  const workDir = join(config.output.dir, "tmp", jobId);
+  await mkdir(workDir, { recursive: true });
+
+  const job: PipelineJob = {
+    id: jobId,
+    input,
+    status: "running",
+    createdAt: Date.now(),
+    updatedAt: Date.now(),
+  };
+
+  try {
+    // Stage 1: Parse
+    callbacks?.onStageStart?.("parse");
+    const parsed = await parseText(input.text, input.template, {
+      llm: config.llm,
+    });
+    job.parsed = parsed;
+    callbacks?.onStageComplete?.("parse");
+
+    // Stage 2: TTS (can be skipped with --no-tts)
+    callbacks?.onStageStart?.("tts");
+    const tts = await generateTTS(parsed, workDir, {
+      provider: input.ttsProvider,
+      voiceId: input.voiceId,
+      format: config.tts.format,
+      speed: config.tts.speed,
+      skip: config.skipTts,
+    });
+    job.tts = tts;
+    callbacks?.onStageComplete?.("tts");
+
+    // Stage 3: Assets
+    callbacks?.onStageStart?.("assets");
+    const assets = await resolveAssets(
+      parsed, workDir, config.assets.root, config.assets.inputDir,
+      { template: input.template, aspect: PLATFORM_PRESETS[input.platform].aspect }
+    );
+    callbacks?.onStageComplete?.("assets");
+
+    // Stage 4: Compose
+    callbacks?.onStageStart?.("compose");
+    const composed = composeProject(parsed, tts, assets, input.platform, input.template);
+    job.composed = composed;
+    callbacks?.onStageComplete?.("compose");
+
+    // Stage 5: Render
+    callbacks?.onStageStart?.("render");
+    const renderOutput = join(workDir, "render.mp4");
+    await renderVideo(composed, renderOutput, config.templates.entryPoint);
+    callbacks?.onStageComplete?.("render");
+
+    // Stage 6: Export
+    callbacks?.onStageStart?.("export");
+    const exported = await exportVideo(
+      renderOutput,
+      composed,
+      config.output.dir,
+      jobId
+    );
+    job.exported = exported;
+    callbacks?.onStageComplete?.("export");
+
+    job.status = "completed";
+  } 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)));
+  }
+
+  job.updatedAt = Date.now();
+  return job;
+}

+ 128 - 0
packages/core/src/stages/assets.ts

@@ -0,0 +1,128 @@
+import type { ParsedContent, AssetManifest, SceneImage, TemplateType, AspectRatio } from "@pipeline/shared";
+import { join, resolve, extname, basename } from "node:path";
+import { mkdir, writeFile, copyFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+
+export interface AssetsConfig {
+  template: TemplateType;
+  aspect: AspectRatio;
+}
+
+export async function resolveAssets(
+  parsed: ParsedContent,
+  workDir: string,
+  assetsRoot: string,
+  inputDir: string,
+  assetsConfig?: AssetsConfig
+): Promise<AssetManifest> {
+  const assetsDir = join(workDir, "assets");
+  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);
+    }
+  }
+
+  // Deduplicate
+  const seen = new Set<string>();
+  const uniqueImages = imageEntries.filter((img) => {
+    const key = img.path || img.url || img.query || "";
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+
+  // Resolve images: path > url > query
+  const images: AssetManifest["images"] = [];
+  for (let i = 0; i < uniqueImages.length; i++) {
+    const img = uniqueImages[i];
+
+    // 1. Local file path (relative to inputDir)
+    if (img.path) {
+      const resolved = resolve(inputDir, img.path);
+      if (existsSync(resolved)) {
+        const filename = `image-${i}${extname(resolved) || ".png"}`;
+        const localPath = join(assetsDir, filename);
+        await copyFile(resolved, localPath);
+        images.push({
+          id: `img-${i}`,
+          localPath,
+          path: img.path,
+          url: img.url,
+          query: img.query,
+          altText: img.query || basename(resolved),
+        });
+        continue;
+      }
+    }
+
+    // 2. Remote URL
+    if (img.url) {
+      try {
+        const resp = await fetch(img.url);
+        if (resp.ok) {
+          const buf = Buffer.from(await resp.arrayBuffer());
+          const urlExt = img.url.endsWith(".png") ? ".png" : ".jpg";
+          const filename = `image-${i}${urlExt}`;
+          const localPath = join(assetsDir, filename);
+          await writeFile(localPath, buf);
+          images.push({
+            id: `img-${i}`,
+            localPath,
+            sourceUrl: img.url,
+            url: img.url,
+            query: img.query,
+            altText: img.query,
+          });
+          continue;
+        }
+      } catch {
+        // Fall through to query fallback
+      }
+    }
+
+    // 3. Query keyword (placeholder)
+    if (img.query) {
+      images.push({
+        id: `img-${i}`,
+        localPath: join(assetsRoot, "backgrounds", "default.png"),
+        query: img.query,
+        altText: img.query,
+      });
+    }
+  }
+
+  // Backgrounds: pick template-specific background for each scene
+  const aspectKey = assetsConfig?.aspect === "9:16" ? "9x16" : "16x9";
+  const templateBg = assetsConfig
+    ? join(assetsRoot, "backgrounds", `default-${assetsConfig.template}-${aspectKey}.png`)
+    : null;
+  const fallbackBg = join(assetsRoot, "backgrounds", "default.png");
+  const bgPath = templateBg && existsSync(templateBg) ? templateBg : fallbackBg;
+
+  const backgrounds = parsed.scenes.map((_, i) => ({
+    id: `bg-${i}`,
+    localPath: bgPath,
+  }));
+
+  return {
+    images,
+    backgrounds,
+    icons: [],
+    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"),
+      },
+    ],
+  };
+}

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

@@ -0,0 +1,90 @@
+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
+): 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;
+
+    // Convert word timestamps from global offsets to scene-relative time
+    const sceneStartTime = ttsScene?.startOffsetSeconds ?? 0;
+    const wordTimestamps = (ttsScene?.wordTimestamps ?? []).map((wt) => ({
+      word: wt.word,
+      startSeconds: wt.startSeconds - sceneStartTime,
+      endSeconds: wt.endSeconds - sceneStartTime,
+    }));
+
+    return {
+      id: scene.id,
+      sceneType: scene.sceneType,
+      startFrame,
+      endFrame,
+      narration: scene.narration,
+      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)
+          );
+          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",
+    };
+  });
+
+  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,
+  };
+}

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

@@ -0,0 +1,38 @@
+import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
+import { join } from "node:path";
+import { mkdir } from "node:fs/promises";
+
+export async function exportVideo(
+  renderedPath: string,
+  project: ComposedProject,
+  outputDir: string,
+  jobId: string
+): Promise<ExportManifest> {
+  await mkdir(outputDir, { 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);
+  await copyFile(renderedPath, finalPath);
+
+  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,
+  };
+}

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

@@ -0,0 +1,80 @@
+import { VideoInputSchema, ParsedContentSchema } from "@pipeline/shared";
+import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
+
+export interface ParseStageConfig {
+  llm: {
+    baseURL?: string;
+    apiKey?: string;
+    model: string;
+  };
+}
+
+export async function parseText(
+  text: string,
+  _template: string,
+  _config: ParseStageConfig
+): Promise<ParsedContent> {
+  const parsed = JSON.parse(text);
+  const videoInput: VideoInput = VideoInputSchema.parse(parsed);
+
+  const scenes: Scene[] = [];
+  let sceneIndex = 0;
+
+  // Cover scene (optional)
+  if (videoInput.cover) {
+    scenes.push({
+      id: "cover",
+      index: sceneIndex++,
+      sceneType: "cover",
+      narration: "",
+      title: videoInput.title,
+      keyframes: videoInput.cover.keyframes ?? [],
+      images: [
+        ...(videoInput.cover.imagePath ? [{ path: videoInput.cover.imagePath }] : []),
+        ...(videoInput.cover.imageUrl ? [{ url: videoInput.cover.imageUrl }] : []),
+        ...(videoInput.cover.imageQuery ? [{ query: videoInput.cover.imageQuery }] : []),
+      ],
+      backgroundQuery: videoInput.cover.imageQuery,
+    });
+  }
+
+  // Content scenes
+  for (const s of videoInput.scenes) {
+    scenes.push({
+      id: s.id,
+      index: sceneIndex++,
+      sceneType: "content",
+      narration: s.narration,
+      title: s.title,
+      keyframes: s.keyframes ?? [],
+      images: s.images,
+      duration: s.duration,
+      speed: s.speed,
+      layoutHint: s.layoutHint,
+    });
+  }
+
+  // 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: [],
+    });
+  }
+
+  const result: ParsedContent = {
+    title: videoInput.title,
+    subtitle: videoInput.subtitle,
+    summary: videoInput.summary || "",
+    cover: videoInput.cover,
+    scenes,
+    outro: videoInput.outro,
+    globalStyle: videoInput.globalStyle || { tone: "formal", pace: "normal" },
+  };
+
+  return ParsedContentSchema.parse(result);
+}

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

@@ -0,0 +1,110 @@
+import { basename, dirname, join } from "node:path";
+import { copyFile, mkdir } from "node:fs/promises";
+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): 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 {}
+      }
+    }
+  }
+
+  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,
+    globalStyle: { tone: "formal", pace: "normal" },
+  };
+}
+
+export async function renderVideo(
+  project: ComposedProject,
+  outputPath: string,
+  templatesEntry: string
+): Promise<RenderResult> {
+  const publicDir = await preparePublicDir(project);
+  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,
+  };
+}

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

@@ -0,0 +1,131 @@
+import type { TTSStageResult, TTSSceneResult, ParsedContent } from "@pipeline/shared";
+import { getProvider } 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;
+  format?: "mp3" | "wav" | "pcm";
+  speed?: number;
+  skip?: boolean;
+}
+
+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 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;
+
+    sceneResults.push({
+      sceneId: scene.id,
+      audioFilePath: result.audioFilePath,
+      durationSeconds: sceneDuration,
+      startOffsetSeconds: currentOffset,
+      wordTimestamps: result.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: currentOffset + i * durationPerWord,
+        endSeconds: currentOffset + (i + 1) * durationPerWord,
+      })),
+    });
+
+    currentOffset += duration;
+  }
+
+  return {
+    audioFilePath: combinedPath,
+    totalDurationSeconds: currentOffset,
+    scenes: sceneResults,
+  };
+}

+ 14 - 0
packages/core/tsconfig.json

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

+ 23 - 0
packages/shared/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "@pipeline/shared",
+  "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": {
+    "zod": "^3.24.0",
+    "openai": "^4.90.0",
+    "yaml": "^2.7.0"
+  }
+}

+ 40 - 0
packages/shared/src/constants.ts

@@ -0,0 +1,40 @@
+export type TemplateType = "news" | "knowledge" | "opinion" | "marketing";
+
+export type PlatformPreset =
+  | "bilibili"
+  | "douyin-long"
+  | "douyin-short"
+  | "universal-16x9"
+  | "universal-9x16";
+
+export type AspectRatio = "16:9" | "9:16";
+
+export type Tone = "formal" | "casual" | "energetic" | "calm";
+export type Pace = "slow" | "normal" | "fast";
+export type JobStatus = "pending" | "running" | "completed" | "failed";
+
+export const PLATFORM_PRESETS: Record<
+  PlatformPreset,
+  { width: number; height: number; fps: number; aspect: AspectRatio }
+> = {
+  bilibili: { width: 1920, height: 1080, fps: 30, aspect: "16:9" },
+  "douyin-long": { width: 1080, height: 1920, fps: 30, aspect: "9:16" },
+  "douyin-short": { width: 1080, height: 1920, fps: 30, aspect: "9:16" },
+  "universal-16x9": { width: 1920, height: 1080, fps: 30, aspect: "16:9" },
+  "universal-9x16": { width: 1080, height: 1920, fps: 30, aspect: "9:16" },
+};
+
+export const TEMPLATE_TYPES: TemplateType[] = [
+  "news",
+  "knowledge",
+  "opinion",
+  "marketing",
+];
+
+export const PLATFORM_PRESET_KEYS: PlatformPreset[] = [
+  "bilibili",
+  "douyin-long",
+  "douyin-short",
+  "universal-16x9",
+  "universal-9x16",
+];

+ 16 - 0
packages/shared/src/index.ts

@@ -0,0 +1,16 @@
+export * from "./types/index.js";
+export {
+  PLATFORM_PRESETS,
+  TEMPLATE_TYPES,
+  PLATFORM_PRESET_KEYS,
+} from "./constants.js";
+export type {
+  TemplateType,
+  PlatformPreset,
+  AspectRatio,
+  Tone,
+  Pace,
+  JobStatus,
+} from "./constants.js";
+export { LLMClient, getParsePrompt } from "./llm/index.js";
+export type { LLMClientConfig } from "./llm/index.js";

+ 36 - 0
packages/shared/src/llm/client.ts

@@ -0,0 +1,36 @@
+import OpenAI from "openai";
+
+export interface LLMClientConfig {
+  baseURL?: string;
+  apiKey?: string;
+  model: string;
+}
+
+export class LLMClient {
+  private client: OpenAI;
+  private model: string;
+
+  constructor(config: LLMClientConfig) {
+    this.client = new OpenAI({
+      baseURL: config.baseURL || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
+      apiKey: config.apiKey || process.env.OPENAI_API_KEY,
+    });
+    this.model = config.model;
+  }
+
+  async chat(systemPrompt: string, userMessage: string): Promise<string> {
+    const response = await this.client.chat.completions.create({
+      model: this.model,
+      messages: [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: userMessage },
+      ],
+      temperature: 0.3,
+      response_format: { type: "json_object" },
+    });
+
+    const content = response.choices[0]?.message?.content;
+    if (!content) throw new Error("LLM returned empty response");
+    return content;
+  }
+}

+ 3 - 0
packages/shared/src/llm/index.ts

@@ -0,0 +1,3 @@
+export { LLMClient } from "./client.js";
+export type { LLMClientConfig } from "./client.js";
+export { getParsePrompt } from "./prompts/parse-text.js";

+ 75 - 0
packages/shared/src/llm/prompts/parse-text.ts

@@ -0,0 +1,75 @@
+import type { TemplateType } from "../../constants.js";
+
+export function getParsePrompt(
+  template: TemplateType
+): string {
+  const base = `You are a video script structuring assistant. Convert the input text into a JSON object for video generation.
+
+The output JSON must follow this exact schema:
+{
+  "title": "string — video title, concise and catchy",
+  "summary": "string — one-sentence summary",
+  "scenes": [
+    {
+      "id": "scene-N",
+      "index": 0,
+      "narration": "string — the voiceover text for this scene (Chinese, natural and conversational)",
+      "keyframes": [
+        { "type": "text|highlight|image|icon|transition", "content": "string" }
+      ],
+      "durationHint": 10,
+      "backgroundQuery": "string — describe the ideal background image",
+      "layoutHint": "full-text|split|centered|bullet-list"
+    }
+  ],
+  "globalStyle": {
+    "tone": "formal|casual|energetic|calm",
+    "pace": "slow|normal|fast",
+    "targetDuration": 60
+  }
+}
+
+Rules:
+- Each scene should be 5-15 seconds of narration
+- narration must be under 200 characters per scene — if content is long, split it into more scenes rather than making narration too long
+- narration should be natural spoken Chinese, suitable for TTS
+- Split the content into 3-8 scenes depending on length
+- keyframes should highlight the main visual points
+- The first scene should be an introduction, the last should be a conclusion/call-to-action`;
+
+  const templateSpecific: Record<TemplateType, string> = {
+    news: `
+Template: News Report (资讯报道)
+- Use a formal, authoritative tone
+- First scene: headline + breaking news intro
+- Middle scenes: key facts, data points, quotes from sources
+- Last scene: summary + implications
+- layoutHint preference: "split" for data, "full-text" for quotes`,
+
+    knowledge: `
+Template: Knowledge Explanation (知识讲解)
+- Use a calm, educational tone
+- First scene: topic introduction + why it matters
+- Middle scenes: step-by-step explanation with clear logic
+- Last scene: key takeaways + further reading suggestion
+- layoutHint preference: "centered" for key concepts, "bullet-list" for steps`,
+
+    opinion: `
+Template: Opinion Sharing (观点分享)
+- Use a casual, conversational tone
+- First scene: hook + personal stance
+- Middle scenes: arguments, examples, counterpoints
+- Last scene: conclusion + call for discussion
+- layoutHint preference: "centered" for quotes, "full-text" for arguments`,
+
+    marketing: `
+Template: Product Marketing (产品营销)
+- Use an energetic, persuasive tone
+- First scene: pain point / hook
+- Middle scenes: product features, benefits, social proof
+- Last scene: CTA (call-to-action) + pricing/offer
+- layoutHint preference: "split" for features, "centered" for CTA`,
+  };
+
+  return base + "\n\n" + templateSpecific[template];
+}

+ 32 - 0
packages/shared/src/types/asset.ts

@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+export const ResolvedAssetSchema = z.object({
+  id: z.string(),
+  localPath: z.string(),
+  path: z.string().optional(),
+  sourceUrl: z.string().optional(),
+  url: z.string().optional(),
+  query: z.string().optional(),
+  altText: z.string().optional(),
+  width: z.number().optional(),
+  height: z.number().optional(),
+});
+
+export type ResolvedAsset = z.infer<typeof ResolvedAssetSchema>;
+
+export const ResolvedFontSchema = z.object({
+  family: z.string(),
+  weight: z.number(),
+  filePath: z.string(),
+});
+
+export type ResolvedFont = z.infer<typeof ResolvedFontSchema>;
+
+export const AssetManifestSchema = z.object({
+  images: z.array(ResolvedAssetSchema),
+  backgrounds: z.array(ResolvedAssetSchema),
+  icons: z.array(ResolvedAssetSchema),
+  fonts: z.array(ResolvedFontSchema),
+});
+
+export type AssetManifest = z.infer<typeof AssetManifestSchema>;

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

@@ -0,0 +1,51 @@
+export {
+  WordTimestampSchema,
+  KeyframeSchema,
+  SceneImageSchema,
+  VideoCoverSchema,
+  VideoOutroSchema,
+  GlobalStyleHintsSchema,
+  InputSceneSchema,
+  VideoInputSchema,
+  SceneSchema,
+  ParsedContentSchema,
+} from "./scene.js";
+export type {
+  WordTimestamp,
+  Keyframe,
+  SceneImage,
+  VideoCover,
+  VideoOutro,
+  GlobalStyleHints,
+  InputScene,
+  VideoInput,
+  Scene,
+  ParsedContent,
+} from "./scene.js";
+
+export { TTSSynthesizeRequestSchema, TTSResultSchema, VoiceInfoSchema, VoiceFilterSchema } from "./tts.js";
+export type { TTSSynthesizeRequest, TTSResult, VoiceInfo, VoiceFilter } from "./tts.js";
+
+export { ResolvedAssetSchema, ResolvedFontSchema, AssetManifestSchema } from "./asset.js";
+export type { ResolvedAsset, ResolvedFont, AssetManifest } from "./asset.js";
+
+export {
+  PipelineInputSchema,
+  PipelineJobSchema,
+  TTSStageResultSchema,
+  TTSSceneResultSchema,
+  ComposedSceneSchema,
+  ComposedProjectSchema,
+  ExportFileSchema,
+  ExportManifestSchema,
+} from "./pipeline.js";
+export type {
+  PipelineInput,
+  PipelineJob,
+  TTSStageResult,
+  TTSSceneResult,
+  ComposedScene,
+  ComposedProject,
+  ExportFile,
+  ExportManifest,
+} from "./pipeline.js";

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

@@ -0,0 +1,137 @@
+import { z } from "zod";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema } from "./scene.js";
+
+export const PipelineInputSchema = z.object({
+  text: z.string(),
+  template: z.enum(["news", "knowledge", "opinion", "marketing"]),
+  platform: z.enum([
+    "bilibili",
+    "douyin-long",
+    "douyin-short",
+    "universal-16x9",
+    "universal-9x16",
+  ]),
+  ttsProvider: z.enum(["fish-audio", "minimax", "elevenlabs", "openai-tts"]),
+  voiceId: z.string().optional(),
+  outputPath: z.string().optional(),
+});
+
+export type PipelineInput = z.infer<typeof PipelineInputSchema>;
+
+export const TTSSceneResultSchema = z.object({
+  sceneId: z.string(),
+  audioFilePath: z.string(),
+  durationSeconds: z.number(),
+  startOffsetSeconds: z.number(),
+  wordTimestamps: z.array(WordTimestampSchema),
+});
+
+export type TTSSceneResult = z.infer<typeof TTSSceneResultSchema>;
+
+export const TTSStageResultSchema = z.object({
+  audioFilePath: z.string(),
+  totalDurationSeconds: z.number(),
+  scenes: z.array(TTSSceneResultSchema),
+});
+
+export type TTSStageResult = z.infer<typeof TTSStageResultSchema>;
+
+export const ResolvedImageSchema = z.object({
+  url: z.string().optional(),
+  query: z.string().optional(),
+  localPath: z.string(),
+});
+
+export type ResolvedImage = z.infer<typeof ResolvedImageSchema>;
+
+export const ComposedKeyframeSchema = z.object({
+  type: z.enum(["text", "highlight", "image", "icon", "transition"]),
+  content: z.string(),
+  startFrame: z.number().optional(),
+  endFrame: z.number().optional(),
+  style: z.record(z.string()).optional(),
+});
+
+export type ComposedKeyframe = z.infer<typeof ComposedKeyframeSchema>;
+
+export const ComposedSceneSchema = z.object({
+  id: z.string(),
+  sceneType: z.enum(["cover", "content", "outro"]).default("content"),
+  startFrame: z.number(),
+  endFrame: z.number(),
+  narration: z.string(),
+  title: z.string().optional(),
+  wordTimestamps: z.array(WordTimestampSchema),
+  audioPath: z.string().optional(),
+  keyframes: z.array(ComposedKeyframeSchema),
+  images: z.array(ResolvedImageSchema).optional(),
+  backgroundAsset: z
+    .object({
+      id: z.string(),
+      localPath: z.string(),
+    })
+    .optional(),
+  layoutHint: z.string(),
+});
+
+export type ComposedScene = z.infer<typeof ComposedSceneSchema>;
+
+export const ComposedProjectSchema = z.object({
+  compositionId: z.string(),
+  width: z.number(),
+  height: z.number(),
+  fps: z.number(),
+  durationInFrames: z.number(),
+  audioPath: z.string(),
+  scenes: z.array(ComposedSceneSchema),
+  template: z.enum(["news", "knowledge", "opinion", "marketing"]),
+  platform: z.string(),
+  title: z.string().optional(),
+  subtitle: z.string().optional(),
+  outro: z
+    .object({
+      text: z.string(),
+      narration: z.string().optional(),
+      cta: z.string().optional(),
+    })
+    .optional(),
+});
+
+export type ComposedProject = z.infer<typeof ComposedProjectSchema>;
+
+export const ExportFileSchema = z.object({
+  platform: z.string(),
+  filePath: z.string(),
+  fileSizeBytes: z.number(),
+  width: z.number(),
+  height: z.number(),
+  durationSeconds: z.number(),
+  format: z.literal("mp4"),
+  codec: z.literal("h264"),
+});
+
+export type ExportFile = z.infer<typeof ExportFileSchema>;
+
+export const ExportManifestSchema = z.object({
+  jobId: z.string(),
+  createdAt: z.number(),
+  files: z.array(ExportFileSchema),
+  thumbnailPath: z.string().optional(),
+});
+
+export type ExportManifest = z.infer<typeof ExportManifestSchema>;
+
+export const PipelineJobSchema = z.object({
+  id: z.string(),
+  input: PipelineInputSchema,
+  status: z.enum(["pending", "running", "completed", "failed"]),
+  error: z.string().optional(),
+  parsed: ParsedContentSchema.optional(),
+  tts: TTSStageResultSchema.optional(),
+  composed: ComposedProjectSchema.optional(),
+  exported: ExportManifestSchema.optional(),
+  createdAt: z.number(),
+  updatedAt: z.number(),
+});
+
+export type PipelineJob = z.infer<typeof PipelineJobSchema>;

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

@@ -0,0 +1,119 @@
+import { z } from "zod";
+
+// --- Primitives ---
+
+export const WordTimestampSchema = z.object({
+  word: z.string(),
+  startSeconds: z.number(),
+  endSeconds: z.number(),
+  confidence: z.number().optional(),
+});
+
+export type WordTimestamp = z.infer<typeof WordTimestampSchema>;
+
+export const KeyframeSchema = z.object({
+  type: z.enum(["text", "highlight", "image", "icon", "transition"]),
+  content: z.string(),
+  timeOffset: z.number().optional(),
+  style: z.record(z.string()).optional(),
+});
+
+export type Keyframe = z.infer<typeof KeyframeSchema>;
+
+// --- New input types ---
+
+export const SceneImageSchema = z.object({
+  path: z.string().optional(),
+  url: z.string().optional(),
+  query: z.string().optional(),
+});
+
+export type SceneImage = z.infer<typeof SceneImageSchema>;
+
+export const VideoCoverSchema = z.object({
+  imagePath: z.string().optional(),
+  imageUrl: z.string().optional(),
+  imageQuery: z.string().optional(),
+  keyframes: z.array(KeyframeSchema).optional(),
+});
+
+export type VideoCover = z.infer<typeof VideoCoverSchema>;
+
+export const VideoOutroSchema = z.object({
+  text: z.string(),
+  narration: z.string().optional(),
+  cta: z.string().optional(),
+});
+
+export type VideoOutro = z.infer<typeof VideoOutroSchema>;
+
+export const GlobalStyleHintsSchema = z.object({
+  tone: z.enum(["formal", "casual", "energetic", "calm"]),
+  pace: z.enum(["slow", "normal", "fast"]),
+  targetDuration: z.number().optional(),
+});
+
+export type GlobalStyleHints = z.infer<typeof GlobalStyleHintsSchema>;
+
+// --- Scene in user input ---
+
+export const InputSceneSchema = z.object({
+  id: z.string(),
+  title: z.string().optional(),
+  narration: z.string().max(200, "Narration too long — split into more scenes"),
+  keyframes: z.array(KeyframeSchema).optional(),
+  images: z.array(SceneImageSchema).optional(),
+  duration: z.number().optional(),
+  speed: z.number().min(0.5).max(2.0).optional(),
+  layoutHint: z
+    .enum(["full-text", "split", "centered", "bullet-list"])
+    .optional(),
+});
+
+export type InputScene = z.infer<typeof InputSceneSchema>;
+
+// --- Top-level video input ---
+
+export const VideoInputSchema = z.object({
+  title: z.string(),
+  subtitle: z.string().optional(),
+  summary: z.string().optional(),
+  cover: VideoCoverSchema.optional(),
+  scenes: z.array(InputSceneSchema).min(1),
+  outro: VideoOutroSchema.optional(),
+  globalStyle: GlobalStyleHintsSchema.optional(),
+});
+
+export type VideoInput = z.infer<typeof VideoInputSchema>;
+
+// --- Internal parsed content (after parse stage) ---
+
+export const SceneSchema = z.object({
+  id: z.string(),
+  index: z.number(),
+  sceneType: z.enum(["cover", "content", "outro"]).default("content"),
+  narration: z.string().max(200),
+  title: z.string().optional(),
+  keyframes: z.array(KeyframeSchema),
+  images: z.array(SceneImageSchema).optional(),
+  duration: z.number().optional(),
+  speed: z.number().optional(),
+  backgroundQuery: z.string().optional(),
+  layoutHint: z
+    .enum(["full-text", "split", "centered", "bullet-list"])
+    .optional(),
+});
+
+export type Scene = z.infer<typeof SceneSchema>;
+
+export const ParsedContentSchema = z.object({
+  title: z.string(),
+  subtitle: z.string().optional(),
+  summary: z.string().optional(),
+  cover: VideoCoverSchema.optional(),
+  scenes: z.array(SceneSchema),
+  outro: VideoOutroSchema.optional(),
+  globalStyle: GlobalStyleHintsSchema,
+});
+
+export type ParsedContent = z.infer<typeof ParsedContentSchema>;

+ 48 - 0
packages/shared/src/types/tts.ts

@@ -0,0 +1,48 @@
+import { z } from "zod";
+
+export const TTSSynthesizeRequestSchema = z.object({
+  text: z.string(),
+  voiceId: z.string(),
+  format: z.enum(["mp3", "wav", "pcm"]).default("mp3"),
+  speed: z.number().min(0.5).max(2.0).default(1.0),
+  emotion: z.string().optional(),
+  outputDir: z.string(),
+  filename: z.string(),
+});
+
+export type TTSSynthesizeRequest = z.infer<typeof TTSSynthesizeRequestSchema>;
+
+export const TTSResultSchema = z.object({
+  audioFilePath: z.string(),
+  durationSeconds: z.number(),
+  wordTimestamps: z.array(
+    z.object({
+      word: z.string(),
+      startSeconds: z.number(),
+      endSeconds: z.number(),
+      confidence: z.number().optional(),
+    })
+  ),
+  format: z.string(),
+  sampleRate: z.number().optional(),
+});
+
+export type TTSResult = z.infer<typeof TTSResultSchema>;
+
+export const VoiceInfoSchema = z.object({
+  id: z.string(),
+  name: z.string(),
+  language: z.string(),
+  gender: z.enum(["male", "female", "neutral"]).optional(),
+  previewUrl: z.string().optional(),
+  providerMetadata: z.record(z.unknown()).optional(),
+});
+
+export type VoiceInfo = z.infer<typeof VoiceInfoSchema>;
+
+export const VoiceFilterSchema = z.object({
+  language: z.string().optional(),
+  gender: z.string().optional(),
+});
+
+export type VoiceFilter = z.infer<typeof VoiceFilterSchema>;

+ 8 - 0
packages/shared/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"]
+}

+ 26 - 0
packages/templates/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@pipeline/templates",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "scripts": {
+    "build": "tsc -b",
+    "typecheck": "tsc --noEmit",
+    "studio": "remotion studio"
+  },
+  "dependencies": {
+    "@pipeline/shared": "workspace:*",
+    "remotion": "^4.0.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "@remotion/cli": "^4.0.0",
+    "@remotion/zod-types": "^4.0.0",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0"
+  }
+}

+ 312 - 0
packages/templates/src/Root.tsx

@@ -0,0 +1,312 @@
+import React from "react";
+import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img } from "remotion";
+import type { TemplateType, AspectRatio } from "@pipeline/shared";
+import type { WordTimestamp } from "@pipeline/shared";
+import NewsScene from "./news/index";
+import KnowledgeScene from "./knowledge/index";
+import OpinionScene from "./opinion/index";
+import MarketingScene from "./marketing/index";
+
+export interface RemotionScene {
+  id: string;
+  sceneType: "cover" | "content" | "outro";
+  startFrame: number;
+  endFrame: number;
+  narration: 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;
+}
+
+export interface RemotionProps {
+  scenes: RemotionScene[];
+  template: TemplateType;
+  platform: string;
+  title: string;
+  subtitle?: string;
+  outro?: {
+    text: string;
+    narration?: string;
+    cta?: string;
+  };
+  globalStyle: {
+    tone: string;
+    pace: string;
+  };
+}
+
+const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
+  news: NewsScene,
+  knowledge: KnowledgeScene,
+  opinion: OpinionScene,
+  marketing: MarketingScene,
+};
+
+const RATIO_ID: Record<AspectRatio, string> = {
+  "16:9": "landscape",
+  "9:16": "portrait",
+};
+
+const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
+  "16:9": { width: 1920, height: 1080 },
+  "9:16": { width: 1080, height: 1920 },
+};
+
+const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing"];
+
+const TemplateComposition: React.FC<RemotionProps> = (props) => {
+  const SceneComponent = SCENE_MAP[props.template];
+  const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
+
+  return (
+    <AbsoluteFill style={{ backgroundColor: "#000" }}>
+      {props.scenes.map((scene) => {
+        const duration = scene.endFrame - scene.startFrame;
+        const sceneAudio = scene.audioFilename ? (
+          <Audio src={staticFile(scene.audioFilename)} />
+        ) : null;
+
+        if (scene.sceneType === "cover") {
+          return (
+            <Sequence
+              key={scene.id}
+              from={scene.startFrame}
+              durationInFrames={duration}
+            >
+              {sceneAudio}
+              <CoverScene
+                title={props.title}
+                subtitle={props.subtitle}
+                keyframes={scene.keyframes}
+                backgroundAsset={scene.backgroundAsset}
+                totalFrames={totalFrames}
+              />
+            </Sequence>
+          );
+        }
+        if (scene.sceneType === "outro") {
+          return (
+            <Sequence
+              key={scene.id}
+              from={scene.startFrame}
+              durationInFrames={duration}
+            >
+              {sceneAudio}
+              <OutroScene
+                text={props.outro?.text || scene.narration}
+                cta={props.outro?.cta}
+                totalFrames={totalFrames}
+              />
+            </Sequence>
+          );
+        }
+        return (
+          <Sequence
+            key={scene.id}
+            from={scene.startFrame}
+            durationInFrames={duration}
+          >
+            {sceneAudio}
+            <SceneComponent
+              scene={scene}
+              sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
+              totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
+              totalFrames={totalFrames}
+              title={props.title}
+            />
+          </Sequence>
+        );
+      })}
+    </AbsoluteFill>
+  );
+};
+
+// --- Cover Scene ---
+
+const CoverScene: React.FC<{
+  title: string;
+  subtitle?: string;
+  keyframes: Array<{ type: string; content: string }>;
+  backgroundAsset?: { id: string; filename: string };
+  totalFrames: number;
+}> = ({ title, subtitle, keyframes, backgroundAsset }) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
+
+  return (
+    <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
+      {backgroundAsset && (
+        <Img
+          src={staticFile(backgroundAsset.filename)}
+          style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover", opacity: 0.3 }}
+        />
+      )}
+      <div style={{
+        position: "absolute", inset: 0, display: "flex",
+        flexDirection: "column", justifyContent: "center", alignItems: "center",
+        padding: 80, textAlign: "center",
+      }}>
+        <div style={{
+          fontSize: 72, fontWeight: 800, color: "white",
+          fontFamily: "sans-serif", lineHeight: 1.2, marginBottom: 20,
+          maxWidth: "80%",
+        }}>
+          {title}
+        </div>
+        {subtitle && (
+          <div style={{
+            fontSize: 28, color: "rgba(255,255,255,0.7)",
+            fontFamily: "sans-serif", marginBottom: 40,
+          }}>
+            {subtitle}
+          </div>
+        )}
+        {keyframes.length > 0 && (
+          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+            {keyframes.map((kf, i) => (
+              <div key={i} style={{
+                fontSize: 22, color: "rgba(255,255,255,0.5)",
+                fontFamily: "sans-serif",
+              }}>
+                {kf.content}
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </AbsoluteFill>
+  );
+};
+
+// --- Outro Scene ---
+
+const OutroScene: React.FC<{
+  text: string;
+  cta?: string;
+  totalFrames: number;
+}> = ({ text, cta }) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
+
+  return (
+    <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
+      <div style={{
+        position: "absolute", inset: 0, display: "flex",
+        flexDirection: "column", justifyContent: "center", alignItems: "center",
+        padding: 80, textAlign: "center",
+      }}>
+        <div style={{
+          fontSize: 48, fontWeight: 700, color: "white",
+          fontFamily: "sans-serif", lineHeight: 1.4, marginBottom: 40,
+          maxWidth: "80%",
+        }}>
+          {text}
+        </div>
+        {cta && (
+          <div style={{
+            fontSize: 28, fontWeight: 600, color: "#fbbf24",
+            fontFamily: "sans-serif",
+            padding: "16px 48px",
+            borderRadius: 12,
+            border: "2px solid #fbbf24",
+          }}>
+            {cta}
+          </div>
+        )}
+      </div>
+    </AbsoluteFill>
+  );
+};
+
+import { useCurrentFrame, interpolate } from "remotion";
+
+const defaultProps: RemotionProps = {
+  scenes: [
+    {
+      id: "cover",
+      sceneType: "cover",
+      startFrame: 0,
+      endFrame: 90,
+      narration: "",
+      wordTimestamps: [],
+      audioFilename: "",
+      keyframes: [
+        { type: "text", content: "文本转视频,一键生成" },
+      ],
+      layoutHint: "centered",
+    },
+    {
+      id: "scene-1",
+      sceneType: "content",
+      startFrame: 90,
+      endFrame: 180,
+      narration: "欢迎使用 Pipeline 视频生成工具",
+      wordTimestamps: [],
+      audioFilename: "",
+      keyframes: [
+        { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
+      ],
+      layoutHint: "centered",
+    },
+    {
+      id: "outro",
+      sceneType: "outro",
+      startFrame: 180,
+      endFrame: 240,
+      narration: "感谢观看",
+      wordTimestamps: [],
+      audioFilename: "",
+      keyframes: [],
+      layoutHint: "centered",
+    },
+  ],
+  template: "news",
+  platform: "bilibili",
+  title: "Pipeline Demo",
+  subtitle: "结构化视频生成",
+  outro: { text: "感谢观看", cta: "点赞 | 关注" },
+  globalStyle: { tone: "formal", pace: "normal" },
+};
+
+export const RemotionRoot: React.FC = () => {
+  return (
+    <>
+      {TEMPLATES.map((template) =>
+        (
+          Object.entries(ASPECT_RATIOS) as [
+            AspectRatio,
+            { width: number; height: number },
+          ][]
+        ).map(([ratio, dims]) => (
+          <Composition
+            key={`${template}-${RATIO_ID[ratio]}`}
+            id={`${template}-${RATIO_ID[ratio]}`}
+            component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
+            durationInFrames={180}
+            fps={30}
+            width={dims.width}
+            height={dims.height}
+            defaultProps={defaultProps}
+            calculateMetadata={async (params) => {
+              const p = params.props as unknown as RemotionProps;
+              return {
+                durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
+                props: params.props,
+              };
+            }}
+          />
+        ))
+      )}
+    </>
+  );
+};

+ 72 - 0
packages/templates/src/base/components/animated-text.tsx

@@ -0,0 +1,72 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface AnimatedTextProps {
+  text: string;
+  fontSize?: number;
+  fontWeight?: number;
+  color?: string;
+  align?: "left" | "center" | "right";
+  delay?: number;
+  maxHeight?: number;
+  style?: React.CSSProperties;
+}
+
+export const AnimatedText: React.FC<AnimatedTextProps> = ({
+  text,
+  fontSize = 56,
+  fontWeight = 700,
+  color = "#ffffff",
+  align = "center",
+  delay = 0,
+  maxHeight,
+  style,
+}) => {
+  const frame = useCurrentFrame();
+  const adjustedFrame = Math.max(0, frame - delay);
+
+  const opacity = interpolate(adjustedFrame, [0, 12], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  const translateY = interpolate(adjustedFrame, [0, 15], [30, 0], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  // Scale font size based on text length
+  const len = text.length;
+  const scaledFontSize =
+    len <= 30
+      ? fontSize
+      : len <= 60
+        ? fontSize * 0.85
+        : len <= 100
+          ? fontSize * 0.7
+          : fontSize * 0.58;
+
+  const effectiveMaxHeight = maxHeight ?? (len > 100 ? 280 : undefined);
+
+  return (
+    <div
+      style={{
+        fontSize: scaledFontSize,
+        fontWeight,
+        color,
+        textAlign: align,
+        fontFamily: "sans-serif",
+        opacity,
+        transform: `translateY(${translateY}px)`,
+        lineHeight: 1.3,
+        maxHeight: effectiveMaxHeight,
+        overflow: "hidden",
+        display: "-webkit-box",
+        WebkitLineClamp: effectiveMaxHeight ? Math.floor(effectiveMaxHeight / (scaledFontSize * 1.3)) : undefined,
+        WebkitBoxOrient: "vertical",
+        ...style,
+      }}
+    >
+      {text}
+    </div>
+  );
+};

+ 65 - 0
packages/templates/src/base/components/background.tsx

@@ -0,0 +1,65 @@
+import React from "react";
+import { useCurrentFrame, interpolate } from "remotion";
+import type { ThemeColors } from "../theme/colors";
+
+interface BackgroundProps {
+  colors: ThemeColors;
+  sceneIndex: number;
+  totalScenes: number;
+  imageUrl?: string;
+}
+
+export const Background: React.FC<BackgroundProps> = ({
+  colors,
+  sceneIndex,
+  totalScenes,
+}) => {
+  const frame = useCurrentFrame();
+  const gradientProgress = sceneIndex / Math.max(totalScenes - 1, 1);
+
+  // Subtle zoom animation
+  const scale = interpolate(frame, [0, 300], [1, 1.05], {
+    extrapolateRight: "clamp",
+  });
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        top: 0,
+        left: 0,
+        width: "100%",
+        height: "100%",
+        background: `linear-gradient(135deg, ${colors.gradient[0]} 0%, ${colors.gradient[1]} ${50 + gradientProgress * 30}%, ${colors.primary}22 100%)`,
+        transform: `scale(${scale})`,
+      }}
+    >
+      {/* Decorative grid overlay */}
+      <div
+        style={{
+          position: "absolute",
+          top: 0,
+          left: 0,
+          width: "100%",
+          height: "100%",
+          backgroundImage: `
+            linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
+            linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
+          `,
+          backgroundSize: "80px 80px",
+        }}
+      />
+      {/* Top accent line */}
+      <div
+        style={{
+          position: "absolute",
+          top: 0,
+          left: 0,
+          width: "100%",
+          height: 4,
+          background: `linear-gradient(90deg, ${colors.primary}, ${colors.primaryLight})`,
+        }}
+      />
+    </div>
+  );
+};

+ 76 - 0
packages/templates/src/base/components/lower-third.tsx

@@ -0,0 +1,76 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface LowerThirdProps {
+  title: string;
+  subtitle?: string;
+  color?: string;
+  style?: React.CSSProperties;
+}
+
+export const LowerThird: React.FC<LowerThirdProps> = ({
+  title,
+  subtitle,
+  color = "#1a56db",
+  style,
+}) => {
+  const frame = useCurrentFrame();
+
+  const slideIn = interpolate(frame, [0, 15], [-600, 0], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  const opacity = interpolate(frame, [0, 10], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  const subtitleSlide = interpolate(frame, [5, 20], [-400, 0], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        bottom: 140,
+        left: 0,
+        opacity,
+        ...style,
+      }}
+    >
+      <div
+        style={{
+          transform: `translateX(${slideIn}px)`,
+          backgroundColor: color,
+          padding: "16px 40px 16px 60px",
+          fontSize: 36,
+          fontWeight: 700,
+          color: "white",
+          fontFamily: "sans-serif",
+          borderTopRightRadius: 8,
+          borderBottomRightRadius: 8,
+        }}
+      >
+        {title}
+      </div>
+      {subtitle && (
+        <div
+          style={{
+            transform: `translateX(${subtitleSlide}px)`,
+            backgroundColor: "rgba(0,0,0,0.7)",
+            padding: "10px 40px 10px 60px",
+            fontSize: 24,
+            color: "rgba(255,255,255,0.8)",
+            fontFamily: "sans-serif",
+            borderTopRightRadius: 8,
+            borderBottomRightRadius: 8,
+          }}
+        >
+          {subtitle}
+        </div>
+      )}
+    </div>
+  );
+};

+ 43 - 0
packages/templates/src/base/components/progress-bar.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import { useCurrentFrame, interpolate } from "remotion";
+
+interface ProgressBarProps {
+  totalFrames: number;
+  color?: string;
+  height?: number;
+  position?: "top" | "bottom";
+}
+
+export const ProgressBar: React.FC<ProgressBarProps> = ({
+  totalFrames,
+  color = "#fbbf24",
+  height = 4,
+  position = "bottom",
+}) => {
+  const frame = useCurrentFrame();
+  const progress = interpolate(frame, [0, totalFrames], [0, 100], {
+    extrapolateRight: "clamp",
+  });
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        [position]: 0,
+        left: 0,
+        width: "100%",
+        height,
+        backgroundColor: "rgba(255,255,255,0.1)",
+      }}
+    >
+      <div
+        style={{
+          width: `${progress}%`,
+          height: "100%",
+          backgroundColor: color,
+          transition: "width 0.03s linear",
+        }}
+      />
+    </div>
+  );
+};

+ 42 - 0
packages/templates/src/base/components/split-layout.tsx

@@ -0,0 +1,42 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface SplitLayoutProps {
+  left: React.ReactNode;
+  right: React.ReactNode;
+  ratio?: "1:1" | "2:1" | "1:2";
+  direction?: "horizontal" | "vertical";
+  gap?: number;
+}
+
+export const SplitLayout: React.FC<SplitLayoutProps> = ({
+  left,
+  right,
+  ratio = "1:1",
+  direction = "horizontal",
+  gap = 40,
+}) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [0, 15], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  const leftFlex = ratio === "2:1" ? 2 : ratio === "1:2" ? 1 : 1;
+  const rightFlex = ratio === "2:1" ? 1 : ratio === "1:2" ? 2 : 1;
+
+  return (
+    <div
+      style={{
+        display: "flex",
+        flexDirection: direction === "horizontal" ? "row" : "column",
+        flex: 1,
+        gap,
+        opacity,
+        padding: 60,
+      }}
+    >
+      <div style={{ flex: leftFlex, display: "flex" }}>{left}</div>
+      <div style={{ flex: rightFlex, display: "flex" }}>{right}</div>
+    </div>
+  );
+};

+ 122 - 0
packages/templates/src/base/components/subtitle-bar.tsx

@@ -0,0 +1,122 @@
+import React from "react";
+import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
+import type { WordTimestamp } from "@pipeline/shared";
+
+interface SubtitleBarProps {
+  wordTimestamps: WordTimestamp[];
+  style?: React.CSSProperties;
+}
+
+export const SubtitleBar: React.FC<SubtitleBarProps> = ({
+  wordTimestamps,
+  style,
+}) => {
+  const frame = useCurrentFrame();
+  const { fps } = useVideoConfig();
+  const currentTime = frame / fps;
+
+  // Find current word index
+  let currentWordIndex = -1;
+  for (let i = 0; i < wordTimestamps.length; i++) {
+    if (
+      currentTime >= wordTimestamps[i].startSeconds &&
+      currentTime <= wordTimestamps[i].endSeconds
+    ) {
+      currentWordIndex = i;
+      break;
+    }
+  }
+
+  // Dynamic window size: fewer words when there's lots of text
+  const totalWords = wordTimestamps.length;
+  const windowSize =
+    totalWords > 50 ? 8 : totalWords > 30 ? 12 : 16;
+
+  const start = Math.max(0, currentWordIndex - Math.floor(windowSize / 2));
+  const end = Math.min(wordTimestamps.length, start + windowSize);
+  const visibleWords = wordTimestamps.slice(start, end).map((w, i) => ({
+    text: w.word,
+    globalIndex: start + i,
+  }));
+
+  const opacity = interpolate(frame, [0, 8], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  // Continuous font scaling based on total word count
+  const baseFontSize = 42;
+  const minScale = 0.5;
+  const scaleAt50 = 0.65;
+  const fontSize =
+    totalWords <= 15
+      ? baseFontSize
+      : totalWords <= 50
+        ? baseFontSize * (1 - (1 - scaleAt50) * ((totalWords - 15) / 35))
+        : baseFontSize * (minScale + (scaleAt50 - minScale) * Math.max(0, 1 - (totalWords - 50) / 100));
+
+  // Dynamic max height: smaller when text is dense
+  const maxHeight =
+    totalWords > 50 ? 80 : totalWords > 30 ? 100 : 120;
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        bottom: 60,
+        left: 0,
+        right: 0,
+        display: "flex",
+        justifyContent: "center",
+        opacity,
+        padding: "0 5%",
+        ...style,
+      }}
+    >
+      <div
+        style={{
+          backgroundColor: "rgba(0,0,0,0.8)",
+          borderRadius: 12,
+          padding: "10px 24px",
+          maxWidth: "100%",
+          maxHeight,
+          overflow: "hidden",
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+        }}
+      >
+        <span
+          style={{
+            fontSize,
+            lineHeight: 1.3,
+            fontFamily: "sans-serif",
+            display: "-webkit-box",
+            WebkitLineClamp: 2,
+            WebkitBoxOrient: "vertical",
+            overflow: "hidden",
+          }}
+        >
+          {visibleWords.map((w, i) => {
+            const isCurrent = w.globalIndex === currentWordIndex;
+            const isPast = currentTime > wordTimestamps[w.globalIndex].endSeconds;
+            return (
+              <span
+                key={w.globalIndex}
+                style={{
+                  color: isCurrent
+                    ? "#fbbf24"
+                    : isPast
+                      ? "rgba(255,255,255,0.5)"
+                      : "#ffffff",
+                  fontWeight: isCurrent ? 700 : 400,
+                }}
+              >
+                {w.text}
+              </span>
+            );
+          })}
+        </span>
+      </div>
+    </div>
+  );
+};

+ 79 - 0
packages/templates/src/base/components/transition.tsx

@@ -0,0 +1,79 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface TransitionProps {
+  type?: "fade" | "slide" | "wipe";
+  duration?: number;
+  color?: string;
+}
+
+export const Transition: React.FC<TransitionProps> = ({
+  type = "fade",
+  duration = 12,
+  color = "#000000",
+}) => {
+  const frame = useCurrentFrame();
+
+  if (type === "fade") {
+    const opacity = interpolate(
+      frame,
+      [0, duration, duration + 10, duration * 2 + 10],
+      [0, 1, 1, 0],
+      { extrapolateRight: "clamp" }
+    );
+    return (
+      <div
+        style={{
+          position: "absolute",
+          top: 0,
+          left: 0,
+          width: "100%",
+          height: "100%",
+          backgroundColor: color,
+          opacity,
+          pointerEvents: "none",
+        }}
+      />
+    );
+  }
+
+  if (type === "slide") {
+    const translateXPercent = interpolate(frame, [0, duration], [100, 0], {
+      extrapolateRight: "clamp",
+      easing: Easing.out(Easing.cubic),
+    });
+    return (
+      <div
+        style={{
+          position: "absolute",
+          top: 0,
+          left: 0,
+          width: "100%",
+          height: "100%",
+          backgroundColor: color,
+          transform: `translateX(${translateXPercent}%)`,
+        }}
+      />
+    );
+  }
+
+  // wipe
+  const scaleX = interpolate(frame, [0, duration], [1, 0], {
+    extrapolateRight: "clamp",
+    easing: Easing.inOut(Easing.cubic),
+  });
+  return (
+    <div
+      style={{
+        position: "absolute",
+        top: 0,
+        right: 0,
+        width: "100%",
+        height: "100%",
+        backgroundColor: color,
+        transform: `scaleX(${scaleX})`,
+        transformOrigin: "right",
+      }}
+    />
+  );
+};

+ 36 - 0
packages/templates/src/base/components/watermark.tsx

@@ -0,0 +1,36 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface WatermarkProps {
+  text?: string;
+  opacity?: number;
+}
+
+export const Watermark: React.FC<WatermarkProps> = ({
+  text = "Pipeline",
+  opacity = 0.15,
+}) => {
+  const frame = useCurrentFrame();
+  const fadeIn = interpolate(frame, [0, 30], [0, opacity], {
+    extrapolateRight: "clamp",
+  });
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        top: 24,
+        right: 32,
+        fontSize: 18,
+        fontWeight: 600,
+        color: "white",
+        opacity: fadeIn,
+        fontFamily: "sans-serif",
+        letterSpacing: 2,
+        textTransform: "uppercase",
+      }}
+    >
+      {text}
+    </div>
+  );
+};

+ 43 - 0
packages/templates/src/base/hooks/use-layout.ts

@@ -0,0 +1,43 @@
+import { useVideoConfig, useCurrentFrame } from "remotion";
+
+export type Orientation = "landscape" | "portrait";
+
+export function useOrientation(): Orientation {
+  const { width, height } = useVideoConfig();
+  return width > height ? "landscape" : "portrait";
+}
+
+export function useResponsiveScale(): {
+  scale: number;
+  orientation: Orientation;
+} {
+  const { width } = useVideoConfig();
+  const baseWidth = 1920;
+  const scale = width / baseWidth;
+  return { scale, orientation: scale < 0.7 ? "portrait" : "landscape" };
+}
+
+export function useFrameForTime(
+  wordTimestamps: Array<{ startSeconds: number; endSeconds: number }>,
+  fps: number
+): { currentWordIndex: number; progress: number } {
+  const currentFrame = useCurrentFrame();
+  const currentTime = currentFrame / fps;
+  let currentWordIndex = -1;
+  for (let i = 0; i < wordTimestamps.length; i++) {
+    const w = wordTimestamps[i];
+    if (currentTime >= w.startSeconds && currentTime <= w.endSeconds) {
+      currentWordIndex = i;
+      break;
+    }
+  }
+  return {
+    currentWordIndex,
+    progress:
+      currentWordIndex >= 0 && currentWordIndex < wordTimestamps.length
+        ? (currentTime - wordTimestamps[currentWordIndex].startSeconds) /
+          (wordTimestamps[currentWordIndex].endSeconds -
+            wordTimestamps[currentWordIndex].startSeconds)
+        : 0,
+  };
+}

+ 45 - 0
packages/templates/src/base/theme/colors.ts

@@ -0,0 +1,45 @@
+export const THEMES = {
+  news: {
+    primary: "#1a56db",
+    primaryLight: "#3b82f6",
+    accent: "#dc2626",
+    bg: "#0f172a",
+    bgLight: "#1e293b",
+    text: "#ffffff",
+    textMuted: "#94a3b8",
+    gradient: ["#0f172a", "#1e3a5f"],
+  },
+  knowledge: {
+    primary: "#0d9488",
+    primaryLight: "#14b8a6",
+    accent: "#6b7280",
+    bg: "#0f172a",
+    bgLight: "#1e293b",
+    text: "#ffffff",
+    textMuted: "#94a3b8",
+    gradient: ["#0f172a", "#134e4a"],
+  },
+  opinion: {
+    primary: "#ea580c",
+    primaryLight: "#f97316",
+    accent: "#374151",
+    bg: "#111827",
+    bgLight: "#1f2937",
+    text: "#ffffff",
+    textMuted: "#9ca3af",
+    gradient: ["#111827", "#431407"],
+  },
+  marketing: {
+    primary: "#9333ea",
+    primaryLight: "#a855f7",
+    accent: "#ec4899",
+    bg: "#0a0a0a",
+    bgLight: "#1a1a2e",
+    text: "#ffffff",
+    textMuted: "#a1a1aa",
+    gradient: ["#0a0a0a", "#3b0764"],
+  },
+} as const;
+
+export type ThemeName = keyof typeof THEMES;
+export type ThemeColors = (typeof THEMES)[ThemeName];

+ 4 - 0
packages/templates/src/entry.ts

@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { RemotionRoot } from "./Root";
+
+registerRoot(RemotionRoot);

+ 2 - 0
packages/templates/src/index.ts

@@ -0,0 +1,2 @@
+export { RemotionRoot } from "./Root";
+export type { RemotionProps, RemotionScene } from "./Root";

+ 91 - 0
packages/templates/src/knowledge/components/key-point-card.tsx

@@ -0,0 +1,91 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface KeyPointCardProps {
+  points: string[];
+  color?: string;
+}
+
+export const KeyPointCard: React.FC<KeyPointCardProps> = ({
+  points,
+  color = "#14b8a6",
+}) => {
+  return (
+    <div
+      style={{
+        display: "flex",
+        flexDirection: "column",
+        gap: 16,
+      }}
+    >
+      {points.map((point, i) => {
+        return <KeyPointItem key={i} point={point} index={i} color={color} />;
+      })}
+    </div>
+  );
+};
+
+const KeyPointItem: React.FC<{
+  point: string;
+  index: number;
+  color: string;
+}> = ({ point, index, color }) => {
+  const frame = useCurrentFrame();
+  const delay = index * 10;
+
+  const opacity = interpolate(frame, [delay, delay + 12], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+  });
+
+  const translateY = interpolate(frame, [delay, delay + 15], [20, 0], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `translateY(${translateY}px)`,
+        backgroundColor: "rgba(255,255,255,0.06)",
+        borderRadius: 12,
+        padding: "20px 28px",
+        borderLeft: `3px solid ${color}`,
+        display: "flex",
+        alignItems: "center",
+        gap: 16,
+      }}
+    >
+      <div
+        style={{
+          width: 32,
+          height: 32,
+          borderRadius: 16,
+          backgroundColor: `${color}33`,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          fontSize: 16,
+          fontWeight: 700,
+          color,
+          fontFamily: "sans-serif",
+          flexShrink: 0,
+        }}
+      >
+        {index + 1}
+      </div>
+      <div
+        style={{
+          fontSize: 24,
+          color: "white",
+          fontFamily: "sans-serif",
+          lineHeight: 1.4,
+        }}
+      >
+        {point}
+      </div>
+    </div>
+  );
+};

+ 64 - 0
packages/templates/src/knowledge/components/step-indicator.tsx

@@ -0,0 +1,64 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface StepIndicatorProps {
+  currentStep: number;
+  totalSteps: number;
+  color?: string;
+}
+
+export const StepIndicator: React.FC<StepIndicatorProps> = ({
+  currentStep,
+  totalSteps,
+  color = "#14b8a6",
+}) => {
+  const frame = useCurrentFrame();
+
+  return (
+    <div
+      style={{
+        display: "flex",
+        gap: 12,
+        alignItems: "center",
+      }}
+    >
+      {Array.from({ length: totalSteps }).map((_, i) => {
+        const isActive = i === currentStep;
+        const isPast = i < currentStep;
+        const dotOpacity = interpolate(frame, [i * 5, i * 5 + 8], [0, 1], {
+          extrapolateLeft: "clamp",
+          extrapolateRight: "clamp",
+        });
+
+        return (
+          <React.Fragment key={i}>
+            <div
+              style={{
+                width: isActive ? 36 : 16,
+                height: 16,
+                borderRadius: 8,
+                backgroundColor: isActive
+                  ? color
+                  : isPast
+                    ? `${color}88`
+                    : "rgba(255,255,255,0.15)",
+                opacity: dotOpacity,
+                transition: "all 0.3s",
+              }}
+            />
+            {i < totalSteps - 1 && (
+              <div
+                style={{
+                  width: 32,
+                  height: 2,
+                  backgroundColor: isPast ? `${color}66` : "rgba(255,255,255,0.08)",
+                  opacity: dotOpacity,
+                }}
+              />
+            )}
+          </React.Fragment>
+        );
+      })}
+    </div>
+  );
+};

+ 124 - 0
packages/templates/src/knowledge/index.tsx

@@ -0,0 +1,124 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  interpolate,
+  Easing,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { THEMES } from "../base/theme/colors";
+import { Background } from "../base/components/background";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { AnimatedText } from "../base/components/animated-text";
+import { ProgressBar } from "../base/components/progress-bar";
+import { Watermark } from "../base/components/watermark";
+import { StepIndicator } from "./components/step-indicator";
+import { KeyPointCard } from "./components/key-point-card";
+
+interface KnowledgeSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+}
+
+const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
+  scene,
+  sceneIndex,
+  totalScenes,
+  totalFrames,
+  title,
+}) => {
+  const frame = useCurrentFrame();
+  const colors = THEMES.knowledge;
+
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 10, scene.endFrame - scene.startFrame - 10, scene.endFrame - scene.startFrame],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  return (
+    <AbsoluteFill style={{ opacity: fadeOpacity }}>
+      <Background
+        colors={colors}
+        sceneIndex={sceneIndex}
+        totalScenes={totalScenes}
+      />
+
+      {/* Step indicator */}
+      <div
+        style={{
+          position: "absolute",
+          top: 48,
+          left: 80,
+        }}
+      >
+        <StepIndicator
+          currentStep={sceneIndex}
+          totalSteps={totalScenes}
+          color={colors.primaryLight}
+        />
+      </div>
+
+      {/* Title label */}
+      <div
+        style={{
+          position: "absolute",
+          top: 48,
+          right: 80,
+          fontSize: 18,
+          color: "#94a3b8",
+          fontFamily: "sans-serif",
+        }}
+      >
+        {title}
+      </div>
+
+      {/* Content */}
+      <div
+        style={{
+          position: "absolute",
+          top: 120,
+          left: 80,
+          right: 80,
+          bottom: 160,
+          display: "flex",
+          flexDirection: "column",
+          justifyContent: "center",
+          gap: 32,
+          overflow: "hidden",
+        }}
+      >
+        <AnimatedText
+          text={scene.narration}
+          fontSize={44}
+          color="white"
+          fontWeight={600}
+          delay={5}
+        />
+
+        {scene.keyframes.length > 0 && (
+          <KeyPointCard
+            points={scene.keyframes
+              .filter((kf) => kf.type === "text" || kf.type === "highlight")
+              .map((kf) => kf.content)}
+            color={colors.primaryLight}
+          />
+        )}
+      </div>
+
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      )}
+
+      <ProgressBar totalFrames={totalFrames} color={colors.primaryLight} />
+      <Watermark />
+    </AbsoluteFill>
+  );
+};
+
+export default KnowledgeScene;

+ 81 - 0
packages/templates/src/marketing/components/cta-overlay.tsx

@@ -0,0 +1,81 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface CTAOverlayProps {
+  text: string;
+  subtitle?: string;
+  color?: string;
+}
+
+export const CTAOverlay: React.FC<CTAOverlayProps> = ({
+  text,
+  subtitle,
+  color = "#a855f7",
+}) => {
+  const frame = useCurrentFrame();
+
+  const scale = interpolate(frame, [0, 20], [0.5, 1], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.back(2)),
+  });
+
+  const opacity = interpolate(frame, [0, 15], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `scale(${scale})`,
+        display: "flex",
+        flexDirection: "column",
+        alignItems: "center",
+        gap: 20,
+      }}
+    >
+      <div
+        style={{
+          fontSize: 56,
+          fontWeight: 800,
+          color: "white",
+          fontFamily: "sans-serif",
+          textAlign: "center",
+          lineHeight: 1.2,
+          textShadow: `0 0 40px ${color}66`,
+        }}
+      >
+        {text}
+      </div>
+
+      {subtitle && (
+        <div
+          style={{
+            fontSize: 24,
+            color: "rgba(255,255,255,0.7)",
+            fontFamily: "sans-serif",
+          }}
+        >
+          {subtitle}
+        </div>
+      )}
+
+      {/* CTA Button */}
+      <div
+        style={{
+          marginTop: 16,
+          background: `linear-gradient(135deg, ${color}, #ec4899)`,
+          padding: "18px 48px",
+          borderRadius: 40,
+          fontSize: 24,
+          fontWeight: 700,
+          color: "white",
+          fontFamily: "sans-serif",
+          boxShadow: `0 8px 32px ${color}55`,
+        }}
+      >
+        立即体验
+      </div>
+    </div>
+  );
+};

+ 91 - 0
packages/templates/src/marketing/components/product-showcase.tsx

@@ -0,0 +1,91 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface ProductShowcaseProps {
+  features: string[];
+  accentColor?: string;
+}
+
+export const ProductShowcase: React.FC<ProductShowcaseProps> = ({
+  features,
+  accentColor = "#a855f7",
+}) => {
+  return (
+    <div
+      style={{
+        display: "grid",
+        gridTemplateColumns: features.length <= 2 ? "1fr" : "1fr 1fr",
+        gap: 20,
+      }}
+    >
+      {features.map((feature, i) => (
+        <FeatureItem key={i} feature={feature} index={i} accentColor={accentColor} />
+      ))}
+    </div>
+  );
+};
+
+const FeatureItem: React.FC<{
+  feature: string;
+  index: number;
+  accentColor: string;
+}> = ({ feature, index, accentColor }) => {
+  const frame = useCurrentFrame();
+  const delay = index * 8;
+
+  const opacity = interpolate(frame, [delay, delay + 12], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+  });
+
+  const scale = interpolate(frame, [delay, delay + 10], [0.8, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.back(1.5)),
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `scale(${scale})`,
+        background: `linear-gradient(135deg, rgba(168,85,247,0.15), rgba(236,72,153,0.1))`,
+        borderRadius: 16,
+        padding: "24px 28px",
+        border: "1px solid rgba(168,85,247,0.2)",
+        display: "flex",
+        alignItems: "center",
+        gap: 16,
+      }}
+    >
+      <div
+        style={{
+          width: 40,
+          height: 40,
+          borderRadius: 10,
+          background: `linear-gradient(135deg, ${accentColor}, #ec4899)`,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          fontSize: 18,
+          fontWeight: 800,
+          color: "white",
+          fontFamily: "sans-serif",
+          flexShrink: 0,
+        }}
+      >
+        {index + 1}
+      </div>
+      <div
+        style={{
+          fontSize: 22,
+          color: "white",
+          fontFamily: "sans-serif",
+          lineHeight: 1.3,
+        }}
+      >
+        {feature}
+      </div>
+    </div>
+  );
+};

+ 140 - 0
packages/templates/src/marketing/index.tsx

@@ -0,0 +1,140 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  interpolate,
+  Easing,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { THEMES } from "../base/theme/colors";
+import { Background } from "../base/components/background";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { AnimatedText } from "../base/components/animated-text";
+import { ProgressBar } from "../base/components/progress-bar";
+import { Watermark } from "../base/components/watermark";
+import { ProductShowcase } from "./components/product-showcase";
+import { CTAOverlay } from "./components/cta-overlay";
+
+interface MarketingSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+}
+
+const MarketingScene: React.FC<MarketingSceneProps> = ({
+  scene,
+  sceneIndex,
+  totalScenes,
+  totalFrames,
+  title,
+}) => {
+  const frame = useCurrentFrame();
+  const colors = THEMES.marketing;
+
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, scene.endFrame - scene.startFrame - 8, scene.endFrame - scene.startFrame],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  const isLastScene = sceneIndex === totalScenes - 1;
+  const featureKeyframes = scene.keyframes.filter(
+    (kf) => kf.type === "text" || kf.type === "highlight"
+  );
+
+  return (
+    <AbsoluteFill style={{ opacity: fadeOpacity }}>
+      <Background
+        colors={colors}
+        sceneIndex={sceneIndex}
+        totalScenes={totalScenes}
+      />
+
+      {/* Decorative glow */}
+      <div
+        style={{
+          position: "absolute",
+          top: "30%",
+          left: "50%",
+          width: 600,
+          height: 600,
+          borderRadius: 300,
+          background: `radial-gradient(circle, ${colors.primary}22, transparent 70%)`,
+          transform: "translate(-50%, -50%)",
+        }}
+      />
+
+      {/* Brand label */}
+      <div
+        style={{
+          position: "absolute",
+          top: 40,
+          left: 40,
+          fontSize: 20,
+          fontWeight: 700,
+          color: "rgba(255,255,255,0.3)",
+          fontFamily: "sans-serif",
+          letterSpacing: 4,
+          textTransform: "uppercase",
+        }}
+      >
+        {title}
+      </div>
+
+      {/* Content */}
+      <div
+        style={{
+          position: "absolute",
+          top: 100,
+          left: 80,
+          right: 80,
+          bottom: 140,
+          display: "flex",
+          flexDirection: "column",
+          justifyContent: "center",
+          alignItems: isLastScene ? "center" : "flex-start",
+          gap: 36,
+          overflow: "hidden",
+        }}
+      >
+        {isLastScene ? (
+          // CTA scene
+          <CTAOverlay
+            text={scene.narration}
+            subtitle={featureKeyframes[0]?.content}
+            color={colors.primaryLight}
+          />
+        ) : (
+          <>
+            <AnimatedText
+              text={scene.narration}
+              fontSize={44}
+              color="white"
+              fontWeight={700}
+              delay={5}
+            />
+            {featureKeyframes.length > 0 && (
+              <ProductShowcase
+                features={featureKeyframes.map((kf) => kf.content)}
+                accentColor={colors.primaryLight}
+              />
+            )}
+          </>
+        )}
+      </div>
+
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      )}
+
+      <ProgressBar totalFrames={totalFrames} color={colors.accent} />
+      <Watermark />
+    </AbsoluteFill>
+  );
+};
+
+export default MarketingScene;

+ 66 - 0
packages/templates/src/news/components/headline-card.tsx

@@ -0,0 +1,66 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface HeadlineCardProps {
+  headline: string;
+  detail?: string;
+  index: number;
+}
+
+export const HeadlineCard: React.FC<HeadlineCardProps> = ({
+  headline,
+  detail,
+  index,
+}) => {
+  const frame = useCurrentFrame();
+  const delay = index * 8;
+
+  const opacity = interpolate(frame, [delay, delay + 12], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+  });
+
+  const translateX = interpolate(frame, [delay, delay + 15], [40, 0], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `translateX(${translateX}px)`,
+        backgroundColor: "rgba(255,255,255,0.08)",
+        borderRadius: 12,
+        padding: "24px 32px",
+        borderLeft: "4px solid #3b82f6",
+        marginBottom: 16,
+      }}
+    >
+      <div
+        style={{
+          fontSize: 28,
+          fontWeight: 700,
+          color: "white",
+          fontFamily: "sans-serif",
+          lineHeight: 1.4,
+        }}
+      >
+        {headline}
+      </div>
+      {detail && (
+        <div
+          style={{
+            fontSize: 20,
+            color: "#94a3b8",
+            fontFamily: "sans-serif",
+            marginTop: 8,
+          }}
+        >
+          {detail}
+        </div>
+      )}
+    </div>
+  );
+};

+ 64 - 0
packages/templates/src/news/components/news-ticker.tsx

@@ -0,0 +1,64 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface NewsTickerProps {
+  headlines: string[];
+  speed?: number;
+}
+
+export const NewsTicker: React.FC<NewsTickerProps> = ({
+  headlines,
+  speed = 2,
+}) => {
+  const frame = useCurrentFrame();
+  const text = headlines.join("    ●    ");
+  const fullWidth = text.length * 22;
+  const offset = (frame * speed) % fullWidth;
+
+  return (
+    <div
+      style={{
+        position: "absolute",
+        bottom: 0,
+        left: 0,
+        width: "100%",
+        height: 48,
+        backgroundColor: "#dc2626",
+        overflow: "hidden",
+        display: "flex",
+        alignItems: "center",
+      }}
+    >
+      <div
+        style={{
+          display: "flex",
+          whiteSpace: "nowrap",
+          transform: `translateX(-${offset}px)`,
+        }}
+      >
+        <span
+          style={{
+            fontSize: 22,
+            fontWeight: 600,
+            color: "white",
+            fontFamily: "sans-serif",
+            paddingRight: 100,
+          }}
+        >
+          {text}
+        </span>
+        <span
+          style={{
+            fontSize: 22,
+            fontWeight: 600,
+            color: "white",
+            fontFamily: "sans-serif",
+            paddingRight: 100,
+          }}
+        >
+          {text}
+        </span>
+      </div>
+    </div>
+  );
+};

+ 30 - 0
packages/templates/src/news/components/source-citation.tsx

@@ -0,0 +1,30 @@
+import React from "react";
+import { useCurrentFrame, interpolate } from "remotion";
+
+interface SourceCitationProps {
+  source: string;
+}
+
+export const SourceCitation: React.FC<SourceCitationProps> = ({ source }) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [0, 20], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        position: "absolute",
+        bottom: 64,
+        right: 40,
+        fontSize: 16,
+        color: "#94a3b8",
+        fontFamily: "sans-serif",
+        fontStyle: "italic",
+      }}
+    >
+      来源:{source}
+    </div>
+  );
+};

+ 187 - 0
packages/templates/src/news/index.tsx

@@ -0,0 +1,187 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  Sequence,
+  AbsoluteFill,
+  interpolate,
+  Easing,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { THEMES } from "../base/theme/colors";
+import { Background } from "../base/components/background";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { LowerThird } from "../base/components/lower-third";
+import { ProgressBar } from "../base/components/progress-bar";
+import { Watermark } from "../base/components/watermark";
+import { HeadlineCard } from "./components/headline-card";
+import { NewsTicker } from "./components/news-ticker";
+
+interface NewsSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+}
+
+const NewsScene: React.FC<NewsSceneProps> = ({
+  scene,
+  sceneIndex,
+  totalScenes,
+  totalFrames,
+  title,
+}) => {
+  const frame = useCurrentFrame();
+  const { fps } = useVideoConfig();
+  const colors = THEMES.news;
+
+  // Scene-level fade
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, scene.endFrame - scene.startFrame - 8, scene.endFrame - scene.startFrame],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  return (
+    <AbsoluteFill style={{ opacity: fadeOpacity }}>
+      <Background
+        colors={colors}
+        sceneIndex={sceneIndex}
+        totalScenes={totalScenes}
+      />
+
+      {/* Breaking news badge */}
+      {sceneIndex === 0 && (
+        <div
+          style={{
+            position: "absolute",
+            top: 40,
+            left: 40,
+            display: "flex",
+            alignItems: "center",
+            gap: 16,
+          }}
+        >
+          <div
+            style={{
+              backgroundColor: "#dc2626",
+              color: "white",
+              padding: "8px 20px",
+              fontSize: 20,
+              fontWeight: 800,
+              fontFamily: "sans-serif",
+              borderRadius: 4,
+              letterSpacing: 2,
+            }}
+          >
+            BREAKING
+          </div>
+          <div
+            style={{
+              fontSize: 20,
+              color: "#94a3b8",
+              fontFamily: "sans-serif",
+            }}
+          >
+            {title}
+          </div>
+        </div>
+      )}
+
+      {/* Main content area */}
+      <div
+        style={{
+          position: "absolute",
+          top: 120,
+          left: 80,
+          right: 80,
+          bottom: 160,
+          display: "flex",
+          flexDirection: "column",
+          justifyContent: "center",
+          overflow: "hidden",
+        }}
+      >
+        {sceneIndex === 0 ? (
+          // Intro scene: big headline
+          <div style={{ overflow: "hidden" }}>
+            <div
+              style={{
+                fontSize: scene.narration.length > 60 ? 40 : 64,
+                fontWeight: 800,
+                color: "white",
+                fontFamily: "sans-serif",
+                lineHeight: 1.2,
+                marginBottom: 24,
+                maxHeight: 260,
+                overflow: "hidden",
+                display: "-webkit-box",
+                WebkitLineClamp: 4,
+                WebkitBoxOrient: "vertical",
+              }}
+            >
+              {scene.narration}
+            </div>
+            {scene.keyframes.map((kf, i) => (
+              <HeadlineCard
+                key={i}
+                headline={kf.content}
+                index={i}
+              />
+            ))}
+          </div>
+        ) : (
+          // Other scenes: cards layout
+          <div style={{ overflow: "hidden" }}>
+            <div
+              style={{
+                fontSize: scene.narration.length > 60 ? 30 : 42,
+                fontWeight: 700,
+                color: "white",
+                fontFamily: "sans-serif",
+                marginBottom: 32,
+                lineHeight: 1.3,
+                maxHeight: 200,
+                overflow: "hidden",
+                display: "-webkit-box",
+                WebkitLineClamp: 4,
+                WebkitBoxOrient: "vertical",
+              }}
+            >
+              {scene.narration}
+            </div>
+            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+              {scene.keyframes
+                .filter((kf) => kf.type === "text" || kf.type === "highlight")
+                .map((kf, i) => (
+                  <HeadlineCard
+                    key={i}
+                    headline={kf.content}
+                    index={i}
+                  />
+                ))}
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Subtitle bar */}
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      )}
+
+      {/* News ticker */}
+      <NewsTicker
+        headlines={scene.keyframes.map((kf) => kf.content)}
+      />
+
+      {/* Progress bar */}
+      <ProgressBar totalFrames={totalFrames} color={colors.accent} />
+      <Watermark />
+    </AbsoluteFill>
+  );
+};
+
+export default NewsScene;

+ 74 - 0
packages/templates/src/opinion/components/author-card.tsx

@@ -0,0 +1,74 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface AuthorCardProps {
+  name: string;
+  role?: string;
+}
+
+export const AuthorCard: React.FC<AuthorCardProps> = ({ name, role }) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [0, 15], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+  const translateX = interpolate(frame, [0, 15], [30, 0], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `translateX(${translateX}px)`,
+        display: "flex",
+        alignItems: "center",
+        gap: 16,
+        backgroundColor: "rgba(255,255,255,0.06)",
+        borderRadius: 40,
+        padding: "12px 28px 12px 12px",
+      }}
+    >
+      <div
+        style={{
+          width: 48,
+          height: 48,
+          borderRadius: 24,
+          backgroundColor: "#ea580c",
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          fontSize: 22,
+          fontWeight: 700,
+          color: "white",
+          fontFamily: "sans-serif",
+        }}
+      >
+        {name[0]}
+      </div>
+      <div>
+        <div
+          style={{
+            fontSize: 20,
+            fontWeight: 600,
+            color: "white",
+            fontFamily: "sans-serif",
+          }}
+        >
+          {name}
+        </div>
+        {role && (
+          <div
+            style={{
+              fontSize: 16,
+              color: "#9ca3af",
+              fontFamily: "sans-serif",
+            }}
+          >
+            {role}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};

+ 96 - 0
packages/templates/src/opinion/components/quote-block.tsx

@@ -0,0 +1,96 @@
+import React from "react";
+import { useCurrentFrame, interpolate, Easing } from "remotion";
+
+interface QuoteBlockProps {
+  quote: string;
+  author?: string;
+  color?: string;
+}
+
+export const QuoteBlock: React.FC<QuoteBlockProps> = ({
+  quote,
+  author,
+  color = "#f97316",
+}) => {
+  const frame = useCurrentFrame();
+
+  const opacity = interpolate(frame, [0, 15], [0, 1], {
+    extrapolateRight: "clamp",
+  });
+
+  const scale = interpolate(frame, [0, 12], [0.95, 1], {
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+
+  // Typewriter effect for quote text
+  const charsVisible = Math.floor(
+    interpolate(frame, [5, 5 + quote.length * 1.5], [0, quote.length], {
+      extrapolateRight: "clamp",
+    })
+  );
+  const displayText = quote.slice(0, charsVisible);
+
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `scale(${scale})`,
+        position: "relative",
+        paddingLeft: 40,
+      }}
+    >
+      {/* Quote mark */}
+      <div
+        style={{
+          position: "absolute",
+          top: -20,
+          left: 0,
+          fontSize: 80,
+          lineHeight: 1,
+          color,
+          fontFamily: "Georgia, serif",
+          fontWeight: 700,
+        }}
+      >
+        "
+      </div>
+
+      <div
+        style={{
+          fontSize: 36,
+          fontStyle: "italic",
+          color: "white",
+          fontFamily: "Georgia, serif",
+          lineHeight: 1.6,
+          maxWidth: "90%",
+        }}
+      >
+        {displayText}
+        <span
+          style={{
+            display: "inline-block",
+            width: 2,
+            height: 36,
+            backgroundColor: color,
+            marginLeft: 2,
+            animation: "blink 0.8s infinite",
+          }}
+        />
+      </div>
+
+      {author && (
+        <div
+          style={{
+            marginTop: 20,
+            fontSize: 22,
+            color: "#9ca3af",
+            fontFamily: "sans-serif",
+          }}
+        >
+          — {author}
+        </div>
+      )}
+    </div>
+  );
+};

+ 135 - 0
packages/templates/src/opinion/index.tsx

@@ -0,0 +1,135 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  interpolate,
+  Easing,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { THEMES } from "../base/theme/colors";
+import { Background } from "../base/components/background";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { AnimatedText } from "../base/components/animated-text";
+import { ProgressBar } from "../base/components/progress-bar";
+import { Watermark } from "../base/components/watermark";
+import { QuoteBlock } from "./components/quote-block";
+
+interface OpinionSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+}
+
+const OpinionScene: React.FC<OpinionSceneProps> = ({
+  scene,
+  sceneIndex,
+  totalScenes,
+  totalFrames,
+  title,
+}) => {
+  const frame = useCurrentFrame();
+  const colors = THEMES.opinion;
+
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 10, scene.endFrame - scene.startFrame - 10, scene.endFrame - scene.startFrame],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  return (
+    <AbsoluteFill style={{ opacity: fadeOpacity }}>
+      <Background
+        colors={colors}
+        sceneIndex={sceneIndex}
+        totalScenes={totalScenes}
+      />
+
+      {/* Opinion badge */}
+      <div
+        style={{
+          position: "absolute",
+          top: 40,
+          left: 40,
+          display: "flex",
+          alignItems: "center",
+          gap: 12,
+        }}
+      >
+        <div
+          style={{
+            width: 8,
+            height: 8,
+            borderRadius: 4,
+            backgroundColor: colors.primary,
+          }}
+        />
+        <div
+          style={{
+            fontSize: 18,
+            color: "#9ca3af",
+            fontFamily: "sans-serif",
+            letterSpacing: 1,
+          }}
+        >
+          观点 · {title}
+        </div>
+      </div>
+
+      {/* Main content */}
+      <div
+        style={{
+          position: "absolute",
+          top: 120,
+          left: 80,
+          right: 80,
+          bottom: 160,
+          display: "flex",
+          flexDirection: "column",
+          justifyContent: "center",
+          gap: 40,
+          overflow: "hidden",
+        }}
+      >
+        {sceneIndex === 0 ? (
+          // Opening: quote style
+          <QuoteBlock
+            quote={scene.narration}
+            color={colors.primaryLight}
+          />
+        ) : (
+          <>
+            <AnimatedText
+              text={scene.narration}
+              fontSize={40}
+              color="white"
+              fontWeight={500}
+              delay={5}
+            />
+            {scene.keyframes
+              .filter((kf) => kf.type === "text" || kf.type === "highlight")
+              .map((kf, i) => (
+                <QuoteBlock
+                  key={i}
+                  quote={kf.content}
+                  color={colors.primaryLight}
+                />
+              ))}
+          </>
+        )}
+      </div>
+
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+      )}
+
+      <ProgressBar totalFrames={totalFrames} color={colors.primaryLight} />
+      <Watermark />
+    </AbsoluteFill>
+  );
+};
+
+export default OpinionScene;

+ 23 - 0
packages/templates/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "jsx": "react-jsx",
+    "lib": ["ES2022", "DOM"],
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "declaration": true,
+    "composite": true,
+    "sourceMap": true,
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../shared" }
+  ]
+}

+ 26 - 0
packages/tts/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@pipeline/tts",
+  "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:*",
+    "@remotion/media-parser": "^4.0.467",
+    "zod": "^3.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0"
+  }
+}

+ 38 - 0
packages/tts/src/duration.ts

@@ -0,0 +1,38 @@
+import { parseMedia } from "@remotion/media-parser";
+import { nodeReader } from "@remotion/media-parser/node";
+import { execSync } from "node:child_process";
+
+export async function measureAudioDuration(
+  filePath: string
+): Promise<number> {
+  // Primary: parse audio container headers via @remotion/media-parser (fast, no full decode)
+  try {
+    const result = await parseMedia({
+      src: filePath,
+      reader: nodeReader,
+      fields: { durationInSeconds: true },
+    });
+    if (result.durationInSeconds != null && result.durationInSeconds > 0) {
+      return result.durationInSeconds;
+    }
+  } catch {
+    // Fall through to ffprobe
+  }
+
+  // Fallback: ffprobe (available on system)
+  try {
+    const output = execSync(
+      `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
+      { encoding: "utf-8", timeout: 5000 }
+    ).trim();
+    const dur = parseFloat(output);
+    if (Number.isFinite(dur) && dur > 0) return dur;
+  } catch {
+    // Fall through to bitrate estimate
+  }
+
+  // Last resort: estimate from file size (mp3 @ 128kbps)
+  const { statSync } = await import("node:fs");
+  const size = statSync(filePath).size;
+  return (size * 8) / 128000;
+}

+ 8 - 0
packages/tts/src/index.ts

@@ -0,0 +1,8 @@
+export type { TTSProvider } from "./types.js";
+export { getProvider, listProviderNames, registerProvider } from "./registry.js";
+
+// Auto-register all providers
+import "./providers/fish-audio.js";
+import "./providers/minimax.js";
+import "./providers/elevenlabs.js";
+import "./providers/openai-tts.js";

+ 154 - 0
packages/tts/src/providers/elevenlabs.ts

@@ -0,0 +1,154 @@
+import type {
+  TTSSynthesizeRequest,
+  TTSResult,
+  VoiceInfo,
+  VoiceFilter,
+  WordTimestamp,
+} from "@pipeline/shared";
+import type { TTSProvider } from "../types.js";
+import { registerProvider } from "../registry.js";
+import { measureAudioDuration } from "../duration.js";
+
+class ElevenLabsProvider implements TTSProvider {
+  readonly name = "elevenlabs";
+
+  async synthesize(request: TTSSynthesizeRequest): Promise<TTSResult> {
+    const apiKey = process.env.ELEVENLABS_API_KEY;
+    if (!apiKey) throw new Error("ELEVENLABS_API_KEY not set");
+
+    const model = process.env.ELEVENLABS_MODEL || "eleven_multilingual_v2";
+    const voiceId = request.voiceId || "21m00Tcm4TlvDq8ikWAM"; // Rachel default
+
+    const response = await fetch(
+      `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
+      {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          "xi-api-key": apiKey,
+        },
+        body: JSON.stringify({
+          text: request.text,
+          model_id: model,
+          voice_settings: {
+            speed: request.speed || 1.0,
+          },
+        }),
+      }
+    );
+
+    if (!response.ok) {
+      const body = await response.text();
+      throw new Error(`ElevenLabs API error: ${response.status} ${body}`);
+    }
+
+    const audioBuffer = Buffer.from(await response.arrayBuffer());
+    const outputPath = `${request.outputDir}/${request.filename}.${request.format || "mp3"}`;
+
+    const { writeFile, mkdir } = await import("node:fs/promises");
+    await mkdir(request.outputDir, { recursive: true });
+    await writeFile(outputPath, audioBuffer);
+
+    const durationSeconds = await measureAudioDuration(outputPath);
+
+    // Use forced alignment for precise timestamps
+    const wordTimestamps = await this.align(
+      audioBuffer,
+      request.text
+    ).catch(() =>
+      estimateWordTimestamps(request.text, durationSeconds)
+    );
+
+    return {
+      audioFilePath: outputPath,
+      durationSeconds,
+      wordTimestamps,
+      format: request.format || "mp3",
+    };
+  }
+
+  async listVoices(filter?: VoiceFilter): Promise<VoiceInfo[]> {
+    const apiKey = process.env.ELEVENLABS_API_KEY;
+    if (!apiKey) throw new Error("ELEVENLABS_API_KEY not set");
+
+    const response = await fetch(
+      "https://api.elevenlabs.io/v1/voices",
+      {
+        headers: { "xi-api-key": apiKey },
+      }
+    );
+
+    if (!response.ok) {
+      throw new Error(`ElevenLabs list voices error: ${response.status}`);
+    }
+
+    const data = (await response.json()) as {
+      voices: Array<{
+        voice_id: string;
+        name: string;
+        labels?: Record<string, string>;
+        preview_url?: string;
+      }>;
+    };
+
+    return data.voices.map((v) => ({
+      id: v.voice_id,
+      name: v.name,
+      language: v.labels?.language || "en",
+      gender: (v.labels?.gender as VoiceInfo["gender"]) || undefined,
+      previewUrl: v.preview_url,
+    }));
+  }
+
+  async align(audio: Buffer, text: string): Promise<WordTimestamp[]> {
+    const apiKey = process.env.ELEVENLABS_API_KEY;
+    if (!apiKey) throw new Error("ELEVENLABS_API_KEY not set");
+
+    const formData = new FormData();
+    formData.append("audio", new Blob([audio]), "audio.mp3");
+    formData.append("text", text);
+
+    const response = await fetch(
+      "https://api.elevenlabs.io/v1/forced-alignment",
+      {
+        method: "POST",
+        headers: { "xi-api-key": apiKey },
+        body: formData,
+      }
+    );
+
+    if (!response.ok) {
+      throw new Error(`ElevenLabs forced alignment error: ${response.status}`);
+    }
+
+    const data = (await response.json()) as {
+      alignment: Array<{ word: string; start: number; end: number }>;
+    };
+
+    return (data.alignment || []).map((a) => ({
+      word: a.word,
+      startSeconds: a.start,
+      endSeconds: a.end,
+    }));
+  }
+}
+
+function estimateWordTimestamps(
+  text: string,
+  totalDuration: number
+): WordTimestamp[] {
+  const isChinese = /[一-鿿]/.test(text);
+  const words = isChinese
+    ? text.split("").filter((c) => c.trim())
+    : text.split(/\s+/).filter((w) => w);
+
+  const durationPerWord = totalDuration / Math.max(words.length, 1);
+
+  return words.map((word, i) => ({
+    word,
+    startSeconds: i * durationPerWord,
+    endSeconds: (i + 1) * durationPerWord,
+  }));
+}
+
+registerProvider("elevenlabs", () => new ElevenLabsProvider());

+ 123 - 0
packages/tts/src/providers/fish-audio.ts

@@ -0,0 +1,123 @@
+import type {
+  TTSSynthesizeRequest,
+  TTSResult,
+  VoiceInfo,
+  VoiceFilter,
+  WordTimestamp,
+} from "@pipeline/shared";
+import type { TTSProvider } from "../types.js";
+import { registerProvider } from "../registry.js";
+import { measureAudioDuration } from "../duration.js";
+
+class FishAudioProvider implements TTSProvider {
+  readonly name = "fish-audio";
+
+  async synthesize(request: TTSSynthesizeRequest): Promise<TTSResult> {
+    const apiKey = process.env.FISH_AUDIO_API_KEY;
+    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", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${apiKey}`,
+      },
+      body: JSON.stringify({
+        text: request.text,
+        reference_id: request.voiceId || undefined,
+        format: request.format || "mp3",
+        speed: request.speed,
+        model: model,
+      }),
+    });
+
+    if (!response.ok) {
+      const body = await response.text();
+      throw new Error(`Fish Audio API error: ${response.status} ${body}`);
+    }
+
+    const audioBuffer = Buffer.from(await response.arrayBuffer());
+    const outputPath = `${request.outputDir}/${request.filename}.${request.format || "mp3"}`;
+
+    const { writeFile, mkdir } = await import("node:fs/promises");
+    await mkdir(request.outputDir, { recursive: true });
+    await writeFile(outputPath, audioBuffer);
+
+    const durationSeconds = await measureAudioDuration(outputPath);
+
+    // Fish Audio doesn't return word timestamps in basic mode;
+    // use align() or streaming mode for precise timestamps
+    return {
+      audioFilePath: outputPath,
+      durationSeconds,
+      wordTimestamps: estimateWordTimestamps(request.text, durationSeconds),
+      format: request.format || "mp3",
+    };
+  }
+
+  async listVoices(filter?: VoiceFilter): Promise<VoiceInfo[]> {
+    const apiKey = process.env.FISH_AUDIO_API_KEY;
+    if (!apiKey) throw new Error("FISH_AUDIO_API_KEY not set");
+
+    const params = new URLSearchParams();
+    if (filter?.language) params.set("language", filter.language);
+    params.set("page_size", "50");
+
+    const response = await fetch(
+      `https://api.fish.audio/v1/model?${params}`,
+      {
+        headers: { Authorization: `Bearer ${apiKey}` },
+      }
+    );
+
+    if (!response.ok) {
+      throw new Error(`Fish Audio list voices error: ${response.status}`);
+    }
+
+    const data = (await response.json()) as {
+      items: Array<{
+        id: string;
+        title: string;
+        labels?: Record<string, string>;
+      }>;
+    };
+
+    return data.items.map((item) => ({
+      id: item.id,
+      name: item.title,
+      language: item.labels?.language || "unknown",
+      gender: (item.labels?.gender as VoiceInfo["gender"]) || undefined,
+    }));
+  }
+
+  async align(audio: Buffer, text: string): Promise<WordTimestamp[]> {
+    // Fish Audio forced alignment endpoint
+    // For now, return estimated timestamps
+    return estimateWordTimestamps(
+      text,
+      (audio.length * 8) / 128000
+    );
+  }
+}
+
+function estimateWordTimestamps(
+  text: string,
+  totalDuration: number
+): WordTimestamp[] {
+  // For Chinese text, split by character; for English, split by space
+  const isChinese = /[一-鿿]/.test(text);
+  const words = isChinese
+    ? text.split("").filter((c) => c.trim())
+    : text.split(/\s+/).filter((w) => w);
+
+  const durationPerWord = totalDuration / Math.max(words.length, 1);
+
+  return words.map((word, i) => ({
+    word,
+    startSeconds: i * durationPerWord,
+    endSeconds: (i + 1) * durationPerWord,
+  }));
+}
+
+registerProvider("fish-audio", () => new FishAudioProvider());

+ 106 - 0
packages/tts/src/providers/minimax.ts

@@ -0,0 +1,106 @@
+import type {
+  TTSSynthesizeRequest,
+  TTSResult,
+  VoiceInfo,
+  VoiceFilter,
+  WordTimestamp,
+} from "@pipeline/shared";
+import type { TTSProvider } from "../types.js";
+import { registerProvider } from "../registry.js";
+import { measureAudioDuration } from "../duration.js";
+
+class MiniMaxProvider implements TTSProvider {
+  readonly name = "minimax";
+
+  async synthesize(request: TTSSynthesizeRequest): Promise<TTSResult> {
+    const apiKey = process.env.MINIMAX_API_KEY;
+    const groupId = process.env.MINIMAX_GROUP_ID;
+    if (!apiKey || !groupId) {
+      throw new Error("MINIMAX_API_KEY and MINIMAX_GROUP_ID must be set");
+    }
+
+    const model = process.env.MINIMAX_MODEL || "speech-02-hd";
+    const response = await fetch(
+      `https://api.minimax.chat/v1/t2a_v2?GroupId=${groupId}`,
+      {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          Authorization: `Bearer ${apiKey}`,
+        },
+        body: JSON.stringify({
+          model,
+          text: request.text,
+          voice_setting: {
+            voice_id: request.voiceId,
+            speed: request.speed || 1.0,
+          },
+          audio_setting: {
+            format: request.format || "mp3",
+          },
+        }),
+      }
+    );
+
+    if (!response.ok) {
+      const body = await response.text();
+      throw new Error(`MiniMax API error: ${response.status} ${body}`);
+    }
+
+    const data = (await response.json()) as {
+      data: { audio: string; status: number };
+      extra_info: { audio_length: number };
+    };
+
+    const audioBuffer = Buffer.from(data.data.audio, "base64");
+    const outputPath = `${request.outputDir}/${request.filename}.${request.format || "mp3"}`;
+
+    const { writeFile, mkdir } = await import("node:fs/promises");
+    await mkdir(request.outputDir, { recursive: true });
+    await writeFile(outputPath, audioBuffer);
+
+    const durationSeconds =
+      data.extra_info?.audio_length ||
+      (await measureAudioDuration(outputPath));
+
+    return {
+      audioFilePath: outputPath,
+      durationSeconds,
+      wordTimestamps: estimateWordTimestamps(
+        request.text,
+        durationSeconds
+      ),
+      format: request.format || "mp3",
+    };
+  }
+
+  async listVoices(filter?: VoiceFilter): Promise<VoiceInfo[]> {
+    // MiniMax voice list requires a separate API call
+    // Return empty for now — voice IDs are typically configured
+    return [];
+  }
+
+  async align(audio: Buffer, text: string): Promise<WordTimestamp[]> {
+    return estimateWordTimestamps(text, (audio.length * 8) / 128000);
+  }
+}
+
+function estimateWordTimestamps(
+  text: string,
+  totalDuration: number
+): WordTimestamp[] {
+  const isChinese = /[一-鿿]/.test(text);
+  const words = isChinese
+    ? text.split("").filter((c) => c.trim())
+    : text.split(/\s+/).filter((w) => w);
+
+  const durationPerWord = totalDuration / Math.max(words.length, 1);
+
+  return words.map((word, i) => ({
+    word,
+    startSeconds: i * durationPerWord,
+    endSeconds: (i + 1) * durationPerWord,
+  }));
+}
+
+registerProvider("minimax", () => new MiniMaxProvider());

+ 90 - 0
packages/tts/src/providers/openai-tts.ts

@@ -0,0 +1,90 @@
+import type {
+  TTSSynthesizeRequest,
+  TTSResult,
+  VoiceInfo,
+  WordTimestamp,
+} from "@pipeline/shared";
+import type { TTSProvider } from "../types.js";
+import { registerProvider } from "../registry.js";
+import { measureAudioDuration } from "../duration.js";
+
+class OpenAITTSProvider implements TTSProvider {
+  readonly name = "openai-tts";
+
+  async synthesize(request: TTSSynthesizeRequest): Promise<TTSResult> {
+    const apiKey = process.env.OPENAI_TTS_API_KEY;
+    if (!apiKey) throw new Error("OPENAI_TTS_API_KEY not set");
+
+    const baseURL =
+      process.env.OPENAI_TTS_BASE_URL ||
+      "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`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${apiKey}`,
+      },
+      body: JSON.stringify({
+        input: request.text,
+        model,
+        response_format: request.format || "mp3",
+        speed: request.speed ?? 1.0,
+        voice: request.voiceId || "zh_female_vv_uranus_bigtts",
+      }),
+    });
+
+    if (!response.ok) {
+      const body = await response.text();
+      throw new Error(`OpenAI TTS API error: ${response.status} ${body}`);
+    }
+
+    const audioBuffer = Buffer.from(await response.arrayBuffer());
+    const format = request.format || "mp3";
+    const outputPath = `${request.outputDir}/${request.filename}.${format}`;
+
+    const { writeFile, mkdir } = await import("node:fs/promises");
+    await mkdir(request.outputDir, { recursive: true });
+    await writeFile(outputPath, audioBuffer);
+
+    const durationSeconds = await measureAudioDuration(outputPath);
+
+    return {
+      audioFilePath: outputPath,
+      durationSeconds,
+      wordTimestamps: estimateWordTimestamps(request.text, durationSeconds),
+      format,
+    };
+  }
+
+  async listVoices(): Promise<VoiceInfo[]> {
+    // Voice IDs are configured via Volcengine docs:
+    // https://www.volcengine.com/docs/6561/1257544
+    return [];
+  }
+
+  async align(audio: Buffer, text: string): Promise<WordTimestamp[]> {
+    return estimateWordTimestamps(text, (audio.length * 8) / 128000);
+  }
+}
+
+function estimateWordTimestamps(
+  text: string,
+  totalDuration: number
+): WordTimestamp[] {
+  const isChinese = /[一-鿿]/.test(text);
+  const words = isChinese
+    ? text.split("").filter((c) => c.trim())
+    : text.split(/\s+/).filter((w) => w);
+
+  const durationPerWord = totalDuration / Math.max(words.length, 1);
+
+  return words.map((word, i) => ({
+    word,
+    startSeconds: i * durationPerWord,
+    endSeconds: (i + 1) * durationPerWord,
+  }));
+}
+
+registerProvider("openai-tts", () => new OpenAITTSProvider());

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