Bladeren bron

feat(publish): 飞书通知增强(标题建议 + @ 成员)与渲染后发布清单

渲染完成后的发布/通知链路三项增强:

1. 飞书通知带「标题建议」
   - PublishContext 增加可选 titleSuggestion,由 parsed.title 与 cover 场景
     trendSummary 拼成(github-trending 形如「GitHub 每日热榜|…」)
   - 成功 / 上传失败 / 生成失败三种消息都在头部行后插入,无标题时自动省略

2. 飞书通知支持 @ 成员
   - 新增 FEISHU_AT_OPEN_IDS(逗号分隔 open_id),sendFeishuMessage 同时写入
     消息内 <at> 标签与顶层 at.open_ids 以触发 @ 通知(自定义机器人仅支持 open_id)

3. 渲染后写发布清单(YAML)
   - 新增视频级 publish 块(title/description/tags,github-trending 由 LLM 产出),
     按「视频级字段链」透传 VideoInput→ParsedContent→ComposedProject(不渲染进视频)
   - export 阶段在 MP4 旁写同名 .yaml;分区 tid/category 等平台字段来自
     config.publishMeta[平台][模板],标签合并去重,best-effort 不中断渲染

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 1 dag geleden
bovenliggende
commit
3d91d160a7

+ 4 - 0
.env.example

@@ -59,6 +59,10 @@ OSS_ACCESS_KEY_SECRET=
 # When set, results / failures are pushed after each job.
 FEISHU_WEBHOOK_URL=
 # FEISHU_WEBHOOK_SECRET=
+# @mention these members (by open_id) in every pushed message. Custom bots can
+# only @mention by open_id, and it must resolve in the bot's tenant (usually a
+# group member). Comma-separated for multiple, e.g. ou_abc,ou_def
+# FEISHU_AT_OPEN_IDS=
 
 # --- Logging ---
 # Log level for the pipeline / TTS / publish loggers (debug|info|warn|error).

+ 4 - 1
CLAUDE.md

@@ -44,7 +44,7 @@ packages/templates/ 5 个模板的 Remotion React 组件
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
 5. **render** — 打包 Remotion bundle,将资源复制到 publicDir,通过 `renderMedia` 渲染
-6. **export** — 输出最终 MP4 到**统一输出目录**,按 `{模板名称}/{ISO日期(YYYY-MM-DD)}/{原文件名}.mp4` 自动建子目录
+6. **export** — 输出最终 MP4 到**统一输出目录**,按 `{模板名称}/{ISO日期(YYYY-MM-DD)}/{原文件名}.mp4` 自动建子目录;同时写一份同名 `.yaml` **发布清单**到 MP4 旁边(标题/描述/标签来自 LLM 产出的 `publish` 块;分区 tid/category 等平台特定字段来自 `config.publishMeta[平台][模板]`)。写清单是 best-effort,失败只告警不中断渲染
 7. **publish**(可选,按需开启)— 渲染成功后上传到阿里云 OSS 并推送飞书 webhook:上传成功推送 OSS 资源链接,生成/上传失败推送失败信息。生成失败(未产出文件)也会推送失败信息。未配置 OSS/飞书时跳过
 
 核心类型链:`VideoInput` → `ParsedContent` → `ComposedProject` → Remotion props。
@@ -100,6 +100,7 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 - `TIMEZONE` — github-trending 首屏日期与输出日期子目录所用时区,默认 `Asia/Shanghai`(Docker 同时据此设 `TZ`)
 - `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` — 阿里云 OSS(密钥走 .env)
 - `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` — 飞书自定义机器人 webhook
+- `FEISHU_AT_OPEN_IDS` — 推送消息时 @ 的成员 open_id(逗号分隔多个)。自定义机器人只能按 open_id @人,且需在机器人所在租户/群内可解析;同时写入消息内 `<at user_id="…">` 标签与顶层 `at.open_ids` 以触发通知
 - `SCHEDULES` — JSON 数组,整体覆盖 `config/default.yaml` 的 `schedules`(部署期改定时任务无需重建镜像)
 - `SCHEDULER_ENABLED` — `false` 关闭容器内进程内调度器(默认开启)
 
@@ -118,6 +119,8 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 
 **新增场景字段时三个 schema 都要同步:** `SceneSchema`(scene.ts)、`ComposedSceneSchema`(pipeline.ts)、`RemotionScene`(Root.tsx 的 interface)。还要在 `compose.ts` 手动透传(不是 `...scene` 展开),否则字段会在 compose 阶段丢失。
 
+**视频级(非场景级)字段另走一条链:** 加在 `VideoInputSchema` + `ParsedContentSchema` + `ComposedProjectSchema`(都是项目顶层,不进 `ComposedSceneSchema`/`RemotionScene`),并在 `parse.ts` 的 `result` 与 `compose.ts` 的返回项目里显式透传。`publish`(发布清单用的 标题/描述/标签)就是这种——不渲染进视频,只由 export 阶段写成 MP4 旁边的 `.yaml`。
+
 ## LLM 与 TTS 提示
 
 - `LLMClient`(`packages/shared/src/llm/client.ts`)设置 `max_tokens: 16384`,`chat()` 返回 `{ content, finishReason }`。github-trending 大 trending 列表可能撑爆 token 上限被截断 → JSON 不完整;parse 阶段对解析失败会**重试一次**(追加精简指令让响应在限额内闭合),错误信息带 `finish_reason` 便于诊断

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

@@ -143,6 +143,7 @@ export const renderCommand = new Command("render")
         retentionDays,
       },
       publish,
+      publishMeta: config?.publishMeta,
       skipPublish: noPublish,
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },

+ 18 - 0
config/default.yaml

@@ -70,6 +70,24 @@ feishu:
   webhook: ""                        # FEISHU_WEBHOOK_URL (use .env)
   # secret: ""                       # signing secret — FEISHU_WEBHOOK_SECRET
 
+# Publish manifest — written as {video}.yaml next to each rendered MP4 with the
+# title/description/tags (LLM-produced for github-trending) plus the
+# platform-specific 分区/category defined here. Lookup: publishMeta[platform][template].
+# Only fill the combos you publish; missing combos just omit those keys.
+publishMeta:
+  bilibili:
+    github-trending:
+      tid: 122                  # 分区 id (122 = 科技→科普). Adjust to your target board.
+      tags: ["GitHub", "开源", "热榜"]
+  douyin-long:
+    github-trending:
+      category: "科技"
+      tags: ["GitHub", "开源"]
+  douyin-short:
+    github-trending:
+      category: "科技"
+      tags: ["GitHub", "开源"]
+
 templates:
   news:
     primaryColor: "#1a56db"

+ 2 - 0
docs/DEPLOYMENT.md

@@ -97,6 +97,7 @@ docker compose up --build -d        # 改完代码后重新构建
 | `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` | 否 | 阿里云 OSS;四项齐全才启用上传 |
 | `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` | 否 | OSS 自定义端点 / key 前缀 / 资源链接域名(如 CDN) / 是否 HTTPS |
 | `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
+| `FEISHU_AT_OPEN_IDS` | 否 | 推送消息时 @ 的成员 open_id(逗号分隔多个)。自定义机器人只能按 open_id @人,且需在机器人租户/群内可解析 |
 | `LOG_LEVEL` | 否 | 日志级别 `debug\|info\|warn\|error`,默认 `info` |
 | `PORT` | 否 | 宿主机映射端口,默认 13000 |
 | `SCHEDULES` | 否 | 定时任务(JSON 数组),整体覆盖 `config/default.yaml` 的 `schedules`,便于部署期改而无需重建镜像 |
@@ -149,6 +150,7 @@ schedules:
 
 - OSS 用 `ali-oss` 分片上传;资源链接优先用 `OSS_PUBLIC_DOMAIN`(CDN),否则用 bucket 默认域名。**假定对象为公开读**;若为私读需另行接签名 URL。
 - 飞书支持自定义机器人的**签名校验**(`FEISHU_WEBHOOK_SECRET`),算法为 `base64(HMAC-SHA256(key=timestamp+"\n"+secret, message=""))`。
+- 设置 `FEISHU_AT_OPEN_IDS`(逗号分隔的 open_id)后,每条推送都会 @ 这些成员:消息正文追加 `<at user_id="open_id">` 标签,并在 payload 顶层声明 `at.open_ids`(二者齐备才会真正触发 @ 通知)。注意自定义机器人只能按 **open_id** @人(无通讯录权限,不支持 user_id / 邮箱),且 open_id 须在该机器人租户内可解析(通常为目标群成员)。
 - 上传后的 OSS URL 会回填到导出文件(CLI 打印 `oss: <url>`;WebUI 任务详情页展示「OSS Links」)。
 
 ---

+ 2 - 1
packages/core/package.json

@@ -23,7 +23,8 @@
     "@remotion/bundler": "^4.0.0",
     "ali-oss": "^6.21.0",
     "zod": "^3.24.0",
-    "tmp-promise": "^3.0.3"
+    "tmp-promise": "^3.0.3",
+    "yaml": "^2.7.0"
   },
   "devDependencies": {
     "@types/node": "^22.0.0"

+ 36 - 3
packages/core/src/pipeline.ts

@@ -13,7 +13,7 @@ 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";
+import { exportVideo, type PublishTargetConfig } from "./stages/export.js";
 import { cleanupExpiredOutput } from "./cleanup.js";
 import {
   runPublish,
@@ -49,6 +49,12 @@ export interface PipelineConfig {
   };
   /** OSS upload + Feishu notification, resolved by resolvePublishConfig. */
   publish?: PublishConfig;
+  /**
+   * Per-platform × per-template publish metadata for the sidecar manifest
+   * (partition tid / category, extra tags). Lookup: publishMeta[platform][template].
+   * Missing combos simply omit those keys from the manifest.
+   */
+  publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
   /** Skip the publish stage entirely (e.g. local debugging). */
   skipPublish?: boolean;
   assets: {
@@ -68,6 +74,20 @@ export interface PipelineCallbacks {
   onError?: (stage: string, error: Error) => void;
 }
 
+/**
+ * Compose a suggested video title for Feishu notifications: the parsed title,
+ * joined with the cover scene's one-line trendSummary (github-trending) via a
+ * fullwidth separator when present; otherwise just the title. Returns undefined
+ * when no title is known (e.g. parse failed) so the line can be omitted.
+ */
+function buildTitleSuggestion(
+  title: string | undefined,
+  trendSummary: string | undefined
+): string | undefined {
+  if (!title) return undefined;
+  return trendSummary ? `${title}|${trendSummary}` : title;
+}
+
 export async function runPipeline(
   input: PipelineInput,
   config: PipelineConfig,
@@ -167,7 +187,8 @@ export async function runPipeline(
         renderOutput,
         composed,
         config.output.dir,
-        jobId
+        jobId,
+        config.publishMeta?.[platform]?.[input.template]
       );
       const expFile = exported.files[0];
       log.info(
@@ -191,6 +212,10 @@ export async function runPipeline(
         jobId,
         template: input.template,
         platforms,
+        titleSuggestion: buildTitleSuggestion(
+          parsed.title,
+          parsed.scenes[0]?.trendSummary
+        ),
       };
       try {
         const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
@@ -226,7 +251,15 @@ export async function runPipeline(
     if (!config.skipPublish && config.publish) {
       await notifyGenerationFailure(
         config.publish,
-        { jobId, template: input.template, platforms },
+        {
+          jobId,
+          template: input.template,
+          platforms,
+          titleSuggestion: buildTitleSuggestion(
+            job.parsed?.title,
+            job.parsed?.scenes?.[0]?.trendSummary
+          ),
+        },
         job.error ?? "unknown error"
       ).catch(() => {});
     }

+ 22 - 1
packages/core/src/publish/feishu.ts

@@ -9,6 +9,13 @@ export interface FeishuConfig {
   webhook: string;
   /** Optional signing secret (custom-bot "签名校验"). */
   secret?: string;
+  /**
+   * Optional open_id list to @mention in every pushed message. Custom bots can
+   * only @mention by open_id (no contact permission for user_id/email), and the
+   * open_id must resolve in the bot's tenant — usually a member of the target
+   * group. Configured via FEISHU_AT_OPEN_IDS (comma-separated).
+   */
+  atOpenIds?: string[];
 }
 
 /**
@@ -34,10 +41,24 @@ export async function sendFeishuMessage(config: FeishuConfig, text: string): Pro
     url = `${url}${sep}timestamp=${timestamp}&sign=${encodeURIComponent(signature)}`;
   }
 
+  // Custom bots need BOTH the inline <at> tag (renders the mention) and the
+  // top-level `at.open_ids` array (triggers the notification) for an @ to fire.
+  const atOpenIds = config.atOpenIds ?? [];
+  const bodyText = atOpenIds.length
+    ? `${text}\n${atOpenIds.map((id) => `<at user_id="${id}"></at>`).join(" ")}`
+    : text;
+  const payload: Record<string, unknown> = {
+    msg_type: "text",
+    content: { text: bodyText },
+  };
+  if (atOpenIds.length) {
+    payload.at = { open_ids: atOpenIds };
+  }
+
   const res = await fetch(url, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
-    body: JSON.stringify({ msg_type: "text", content: { text } }),
+    body: JSON.stringify(payload),
   });
 
   if (!res.ok) {

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

@@ -16,6 +16,14 @@ export interface PublishContext {
   jobId: string;
   template: string;
   platforms: string[];
+  /**
+   * Suggested video title for the Feishu message, e.g.
+   * "GitHub 每日热榜|AI Agent 框架持续火热". Combined from the parsed title and
+   * the cover scene's one-line trendSummary (github-trending) when present;
+   * otherwise just the title. Omitted entirely when no title is known
+   * (e.g. parse failed before a title existed).
+   */
+  titleSuggestion?: string;
 }
 
 export interface UploadedFile {
@@ -37,6 +45,13 @@ function parseBool(value: string | undefined, fallback: boolean): boolean {
   return value === "1" || value.toLowerCase() === "true";
 }
 
+/** Parse a comma-separated string OR string[] of open_ids into a clean list. */
+function parseOpenIdList(value: unknown): string[] {
+  if (!value) return [];
+  const arr = Array.isArray(value) ? value : String(value).split(",");
+  return arr.map((v) => String(v).trim()).filter(Boolean);
+}
+
 /**
  * Build a PublishConfig from the raw YAML config merged with environment
  * variables. Env vars take precedence and are the recommended place for secrets.
@@ -67,9 +82,11 @@ export function resolvePublishConfig(
 
   const feishuRaw = raw?.feishu ?? {};
   const webhook = process.env.FEISHU_WEBHOOK_URL || feishuRaw.webhook || "";
+  const atOpenIds = parseOpenIdList(process.env.FEISHU_AT_OPEN_IDS ?? feishuRaw.atOpenIds);
   const feishu: FeishuConfig = {
     webhook,
     secret: process.env.FEISHU_WEBHOOK_SECRET || feishuRaw.secret,
+    ...(atOpenIds.length ? { atOpenIds } : {}),
   };
   const feishuEnabled = Boolean(feishu.webhook);
 
@@ -93,6 +110,11 @@ async function safeNotify(config: FeishuConfig, text: string): Promise<boolean>
   }
 }
 
+/** "标题建议: …" line, or null when no title is known (so it can be filtered out). */
+function titleLine(ctx: PublishContext): string | null {
+  return ctx.titleSuggestion ? `标题建议: ${ctx.titleSuggestion}` : null;
+}
+
 function buildSuccessText(
   ctx: PublishContext,
   uploaded: UploadedFile[],
@@ -103,12 +125,15 @@ function buildSuccessText(
     : files.map((f) => `- [${f.platform}] ${f.filePath}`);
   return [
     "✅ 视频生成成功",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     "资源:",
     ...lines,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 function buildUploadFailureText(
@@ -118,22 +143,28 @@ function buildUploadFailureText(
 ): string {
   return [
     "⚠️ 视频已生成,但 OSS 上传失败",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     `本地文件: ${files.map((f) => f.filePath).join(", ")}`,
     `错误: ${error}`,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 function buildGenerationFailureText(ctx: PublishContext, error: string): string {
   return [
     "❌ 视频生成失败",
+    titleLine(ctx),
     `模板: ${ctx.template}`,
     `平台: ${ctx.platforms.join(", ")}`,
     `任务: ${ctx.jobId}`,
     `错误: ${error}`,
-  ].join("\n");
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 /**

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

@@ -90,5 +90,6 @@ export function composeProject(
     subtitle: parsed.subtitle,
     outro: parsed.outro,
     channelName,
+    publish: parsed.publish,
   };
 }

+ 69 - 4
packages/core/src/stages/export.ts

@@ -1,14 +1,67 @@
 import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
 import { isoDateString } from "@pipeline/shared";
-import { getTimezone } from "@pipeline/shared/node";
+import { getTimezone, createLogger } from "@pipeline/shared/node";
 import { join } from "node:path";
-import { mkdir } from "node:fs/promises";
+import { mkdir, writeFile } from "node:fs/promises";
+import { stringify as stringifyYaml } from "yaml";
+
+const log = createLogger("export");
+
+/**
+ * Per-platform × per-template publish config (from `config.publishMeta`).
+ * Everything except `tags` is emitted verbatim under the manifest's `category`
+ * block, so platform-specific partition keys (bilibili `tid`, douyin `category`,
+ * …) pass through without the code needing to know them.
+ */
+export interface PublishTargetConfig {
+  tags?: string[];
+  tid?: number;
+  category?: string;
+  [key: string]: unknown;
+}
+
+/** Build the sidecar manifest object for one rendered file. Exported for tests. */
+export function buildPublishManifest(
+  project: ComposedProject,
+  fileBasename: string,
+  publishMeta?: PublishTargetConfig
+): Record<string, unknown> {
+  const publish = project.publish;
+  const configTags = publishMeta?.tags ?? [];
+  const llmTags = publish?.tags ?? [];
+  // Merge LLM tags first, then config tags; drop empties + dedupe (case-sensitive).
+  const tags = [...llmTags, ...configTags]
+    .filter((t): t is string => typeof t === "string" && t.length > 0)
+    .filter((t, i, arr) => arr.indexOf(t) === i);
+
+  // category = everything in publishMeta except `tags` (tid / category / …).
+  const category: Record<string, unknown> = {};
+  if (publishMeta) {
+    for (const [k, v] of Object.entries(publishMeta)) {
+      if (k !== "tags") category[k] = v;
+    }
+  }
+
+  const manifest: Record<string, unknown> = {
+    title: publish?.title || project.title || "",
+    description: publish?.description || "",
+    tags,
+    platform: project.platform,
+    template: project.template,
+    file: `${fileBasename}.mp4`,
+    durationSeconds: Number((project.durationInFrames / project.fps).toFixed(2)),
+    date: isoDateString(new Date(), getTimezone()),
+  };
+  if (Object.keys(category).length > 0) manifest.category = category;
+  return manifest;
+}
 
 export async function exportVideo(
   renderedPath: string,
   project: ComposedProject,
   outputDir: string,
-  jobId: string
+  jobId: string,
+  publishMeta?: PublishTargetConfig
 ): Promise<ExportManifest> {
   // Unified layout: {outputDir}/{template}/{ISO-date}/{file}. Date is in the
   // configured timezone (default Asia/Shanghai) so the folder matches the date
@@ -19,10 +72,22 @@ export async function exportVideo(
   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 fileBasename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}`;
+  const filename = `${fileBasename}.mp4`;
   const finalPath = join(subDir, filename);
   await copyFile(renderedPath, finalPath);
 
+  // Sidecar publish manifest (YAML), same basename as the MP4. Best-effort: a
+  // manifest failure must never abort a successful render.
+  try {
+    const manifestPath = join(subDir, `${fileBasename}.yaml`);
+    const manifest = buildPublishManifest(project, fileBasename, publishMeta);
+    await writeFile(manifestPath, stringifyYaml(manifest), "utf8");
+    log.info(`publish manifest -> ${manifestPath}`);
+  } catch (err) {
+    log.warn(`publish manifest write failed (render still succeeded): ${err instanceof Error ? err.message : err}`);
+  }
+
   const files: ExportFile[] = [
     {
       platform: project.platform,

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

@@ -78,6 +78,21 @@ function applyLengthLimits(input: unknown, template: string): unknown {
     if (typeof (root as any).trendSummary === "string") {
       (root as any).trendSummary = clipToLength((root as any).trendSummary, 40);
     }
+    // Clip the publish sidecar metadata too (description ≤200; ≤8 tags, each
+    // ≤20) so an over-budget LLM response still validates instead of failing.
+    if ((root as any).publish && typeof (root as any).publish === "object") {
+      const pub: any = { ...(root as any).publish };
+      if (typeof pub.description === "string") {
+        pub.description = clipToLength(pub.description, 200);
+      }
+      if (Array.isArray(pub.tags)) {
+        pub.tags = pub.tags
+          .filter((t: any) => typeof t === "string" && t.trim())
+          .map((t: any) => clipToLength(t.trim(), 20))
+          .slice(0, 8);
+      }
+      (root as any).publish = pub;
+    }
   }
   return root;
 }
@@ -141,6 +156,16 @@ function sanitizeVideoInput(input: VideoInput): VideoInput {
       ? input.coverTags.map((t) => stripUnsupportedGlyphs(t ?? ""))
       : input.coverTags,
     trendSummary: input.trendSummary ? stripUnsupportedGlyphs(input.trendSummary) : input.trendSummary,
+    publish: input.publish
+      ? {
+          ...input.publish,
+          title: stripUnsupportedGlyphs(input.publish.title ?? ""),
+          description: stripUnsupportedGlyphs(input.publish.description ?? ""),
+          tags: Array.isArray(input.publish.tags)
+            ? input.publish.tags.map((t) => stripUnsupportedGlyphs(t ?? ""))
+            : input.publish.tags,
+        }
+      : input.publish,
     cover: input.cover
       ? { ...input.cover, keyframes: cleanKeyframes(input.cover.keyframes) }
       : input.cover,
@@ -361,6 +386,7 @@ export async function parseText(
     scenes,
     outro: videoInput.outro,
     globalStyle: videoInput.globalStyle || { tone: "formal", pace: "normal" },
+    publish: videoInput.publish,
   };
 
   return ParsedContentSchema.parse(result);

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

@@ -11,6 +11,7 @@ The output JSON must follow this exact schema:
   "title": "string — video title, concise and catchy",
   "summary": "string — one-sentence summary",
   "trendSummary": "string — OPTIONAL, github-trending only. One short sentence (≤30 chars) synthesizing the day's overall trend. Omit for other templates.",
+  "publish": "object — OPTIONAL, github-trending only. Posting metadata for the video page: { title, description, tags[] }. See template-specific rules. Omit for other templates.",
   "scenes": [
     {
       "id": "scene-N",
@@ -97,6 +98,11 @@ Template: GitHub Trending Showcase (GitHub 每日热榜)
 COVER METADATA (github-trending only — emit this TOP-LEVEL field alongside title/summary; omit it for every other template):
 - trendSummary: one short sentence (≤30 chars) synthesizing the overall trend across today's repos — what theme is hot or emerging. Must read as an editorial insight, NOT a restatement of the repo list or a generic filler. Example: "AI Agent 与代码智能工具持续升温". Plain CJK/ASCII only.
 
+PUBLISH METADATA (github-trending only — emit this TOP-LEVEL \`publish\` object alongside title/summary; omit it for every other template). These fields become the video's posting metadata on the target platform (Bilibili/Douyin 简介与标签), NOT anything shown in the video itself:
+- publish.title: a catchy POSTING title for the video page. It MUST be distinct from the fixed cover masthead "GitHub 每日热榜" — weave in the day's overall trend or the single most notable repo. ≤ 40 chars. Example: "GitHub 今日热榜:AI Agent 与代码工具持续升温". Plain CJK/ASCII only.
+- publish.description: a 1–3 sentence video 简介 summarizing which repos/themes today's video covers and why a developer should watch. ≤ 200 chars. Plain CJK/ASCII only.
+- publish.tags: 3–8 话题 tags as an array of short strings, each ≤ 12 chars (e.g. ["GitHub","开源","AI Agent","编程","前端"]). Plain CJK/ASCII only.
+
 PER-REPO SCENE STRUCTURE (mandatory for every repo scene):
 
 displayText: the repo's fullName (e.g. "facebook/react"). Nothing else.

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

@@ -7,6 +7,7 @@ export {
   GlobalStyleHintsSchema,
   InputSceneSchema,
   VideoInputSchema,
+  PublishMetaSchema,
   SceneSchema,
   ParsedContentSchema,
   RepoMetaSchema,
@@ -21,6 +22,7 @@ export type {
   GlobalStyleHints,
   InputScene,
   VideoInput,
+  PublishMeta,
   Scene,
   ParsedContent,
   RepoMeta,

+ 4 - 1
packages/shared/src/types/pipeline.ts

@@ -1,5 +1,5 @@
 import { z } from "zod";
-import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema } from "./scene.js";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema, PublishMetaSchema } from "./scene.js";
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
@@ -102,6 +102,9 @@ export const ComposedProjectSchema = z.object({
     })
     .optional(),
   channelName: z.string().default("Pipeline"),
+  // Threaded from parsed.publish (github-trending) → written to the sidecar
+  // manifest by the export stage. Optional; absent for other templates.
+  publish: PublishMetaSchema.optional(),
 });
 
 export type ComposedProject = z.infer<typeof ComposedProjectSchema>;

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

@@ -104,6 +104,20 @@ export const InputSceneSchema = z.object({
 
 export type InputScene = z.infer<typeof InputSceneSchema>;
 
+// --- Publish metadata (LLM-produced for github-trending) ---
+// Video-level sidecar info for posting the rendered video: a posting title,
+// a 简介/description, and 话题 tags. NOT rendered into the video — it is written
+// to a YAML manifest next to the MP4 by the export stage. Platform-specific
+// fields (分区 tid / category) come from config, not here.
+
+export const PublishMetaSchema = z.object({
+  title: z.string(),
+  description: z.string(),
+  tags: z.array(z.string()).max(10).optional(),
+});
+
+export type PublishMeta = z.infer<typeof PublishMetaSchema>;
+
 // --- Top-level video input ---
 
 export const VideoInputSchema = z.object({
@@ -114,6 +128,9 @@ export const VideoInputSchema = z.object({
   // the cover scene. LLM-produced, surfaced on the opening title screen.
   coverTags: z.array(z.string()).max(5).optional(),
   trendSummary: z.string().max(40).optional(),
+  // github-trending only: posting metadata (title/description/tags) written to
+  // the sidecar manifest. LLM-produced; omitted by other templates.
+  publish: PublishMetaSchema.optional(),
   cover: VideoCoverSchema.optional(),
   scenes: z.array(InputSceneSchema).min(1),
   outro: VideoOutroSchema.optional(),
@@ -156,6 +173,9 @@ export const ParsedContentSchema = z.object({
   scenes: z.array(SceneSchema),
   outro: VideoOutroSchema.optional(),
   globalStyle: GlobalStyleHintsSchema,
+  // Threaded from VideoInput.publish (github-trending) through to the export
+  // stage's sidecar manifest. Optional — absent for templates without it.
+  publish: PublishMetaSchema.optional(),
 });
 
 export type ParsedContent = z.infer<typeof ParsedContentSchema>;

+ 3 - 0
pnpm-lock.yaml

@@ -128,6 +128,9 @@ importers:
       tmp-promise:
         specifier: ^3.0.3
         version: 3.0.3
+      yaml:
+        specifier: ^2.7.0
+        version: 2.9.0
       zod:
         specifier: ^3.24.0
         version: 3.25.76