diff --git a/.gitignore b/.gitignore index 5bb25d69..9322969f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ package-lock.json # default ignore /shelf/ /workspace.xml +/tmp .DS_Store .idea/ __pycache__ @@ -23,7 +24,6 @@ __pycache__/ patchright/ patchright-v*/ openclaw/ -tests/ # addon crews copied into crews/ at install time — not tracked .pnpm-store/ @@ -43,3 +43,4 @@ crews/main/db/ # engagement 技能 probe 调试输出(dump 截图/HTML/innerText,不入仓) *-engagement-probe/ +docs/mediacrawlerpro-catchup-2026-09-19.md diff --git a/AGENTS.md b/AGENTS.md index 71777e93..317d7ae3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,9 @@ +## 测试文件布局 + +- 回归测试与测试夹具统一放在仓根 `test/`,按组件分目录;不要放在正式脚本或 skill 目录中。 +- 移动测试时同步更新导入路径、测试入口与运行说明;确认已无用途的测试可以删除。 +- 用于修改上游测试的补丁材料仍放在对应 `patches/` 补丁包内。 + ## Docker 部署规范 - 用户态镜像、Compose service 和持久卷统一使用 **xiaobei** 命名;不得新增 `wiseflow-*` 镜像或卷名。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee33354e..4c39ff6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +# v5.7.2 (2026-09-20) + +### 视频制作流程与交接 + +- **统一 Stage 0→15 制作流程**:所有视频制作均从 Brief 进入通用阶段链,保留 GATE A 剧本验收、GATE B 素材验收及成片技术自检。移除 `intent-router`;`story-develop` 仅用于 Brief 缺失或不清晰时的需求整理。 +- **类型 workflow 指导剧本生产与自检**:`reversal-ad`、`narration-video`、`collage-broll` 分别定义 Brief→script 的写作规则、检查项和制作约定;`script-write` / `script-self-eval` 按 workflow 生成对应模板。未指定类型时走通用分场剧本。 +- **明确口播与旁白分工**:口播稿由小贝提供或向用户取得,content-producer 原样落稿、不代写;真人录音走时间戳与画面排布;制作解说的旁白由 content-producer 编写并提交 GATE A。DNA 指导选题与 Brief,不延伸接管分镜制作。 +- **拼贴 B-roll 流程对齐**:按隐喻清单生成静帧并交给 `collage-broll render`;被裁剪的分镜、素材规划、逐镜渲染和拼接阶段加入 workflow 守卫,避免误走通用分镜链。 +- **改片与交付约定补齐**:修改单明确问题位置、预期效果与保留项;按受影响片段定向重做,保留上一版并记录修改。AIGC 转场补充选帧、景别、风格与帧率衔接要求,素材清单增加规格说明。 + +### AIGC 供应商与百炼 Agent Plan + +- **百炼 Agent Plan 统一接入**:图像生成、TTS、ASR、声画视频生成支持通过 `AWK_API_KEY` 访问 `token-plan.cn-beijing.maas.aliyuncs.com`。默认百炼配置下,一个账号和 Key 即可覆盖文本、图像、语音与视频能力;同时保留业务空间 `WORKSPACE_ID` + `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` 路线。 +- **图像生成收敛到百炼**:业务空间使用 `qwen-image-3.0-pro` → `qwen-image-3.0` → `qwen-image-2.0-pro-2026-06-22` 候选链;Agent Plan 使用 `wan2.7-image-pro` → `wan2.7-image`。支持文生图、参考图编辑与多图融合,封面及制作工具同步接入。 +- **TTS 增加百炼后端**:支持 `qwen-audio-3.0-tts-plus`,业务空间可回退 `qwen-audio-3.0-tts-flash`;保留火山豆包语音合成。支持普通音频下载、SSE 流式音频与字级时间戳,统一语速、响度、格式和音色处理,并自动执行 ASR 自检。 +- **ASR 统一路由**:按火山 → 百炼业务空间 → 百炼 Agent Plan 尝试已配置后端,失败时回退并汇总错误;百炼使用 `qwen-audio-3.0-asr-flash`,统一输出全文、句段与秒级词时间戳。视频转写、口播剪辑及旁白对齐共用该入口。 +- **视频生成支持 Agent Plan**:百炼 t2v / i2v / r2v 使用 `happyhorse-1.1` → `happyhorse-1.0` → `wan2.7` 对应模式候选链,保留火山 Seedance 与 MiniMax H3。仅配置百炼 `AWK_API_KEY` 时可直接生成,无需额外视频 Key;已有显式视频供应商凭据时仍按配置优先级选择。 +- **Agent Plan 真实调用验证与修复**:TTS 普通/流式、公共 ASR、文生图、三种视频模式及旁白对齐入口均完成实测;视频下载、音视频轨与完整解码通过。修复百炼流式 sentence-end 缺少句级文本时字幕为空的问题,以及旁白对齐的公共 ASR 导入路径错误、百炼结果被误标为火山来源的问题。i2v 实测按首帧比例输出,成片尺寸需检查实际媒体,不能仅依赖请求的 `ratio`。 + +### 抖音图文音乐发布与作品跟踪 + +- **新增抖音图文音乐发布**:`douyin-note-publish` 支持按序上传多图、填写标题与描述话题、读取上传后的实际推荐音乐并选曲;发布前复核配乐,发布后回收 `/note/` 链接。仅在明确选择原声时跳过配乐流程。 +- **图文与视频分开路由**:视频入口改为 `douyin-video-publish`,与图文共享登录态和发布任务锁。修复输入框不兼容的选择器调用;发布结果待核实时只补取链接,避免自动重复发布。 +- **图文发布后取链修复**:兼容管理页将标题与正文合并展示的 DOM,在唯一候选的图文编辑页核验完整标题后返回链接。候选判定与点击合并为一次浏览器操作,修复两次检查间列表刷新导致直接退出的竞态;每轮搜索后等待 5 秒、轮询候选最多 30 秒,超时后间隔 3 秒重新搜索,最多 4 轮。保留多候选停止与禁止自动重发,已通过列表消失后恢复的浏览器回归,并用两条已发布作品复测取链。 +- **创作者侧指标与深度数据**:抖音取数接入创作者 `item/list`,优先采用创作者侧播放量并保留有效零值;兼容图文链接。`deep_metrics` 保存最新 JSON 及采集时间、来源,不累积历史快照。心跳按平台启用状态巡检,抖音聚焦最近 30 条作品,取数失败显式报告。 + +### 平台兼容性修复 + +- 视频号短标题统一无标点、以空格分隔;发布前准备 3:4 / 4:3 封面变体,增强封面编辑操作及 DNA 入库核验,视频描述字数改为建议值并以平台输入框为准。 +- 小红书软风控增加有界冷却与单次重试,安全限制不再直接判定为登录失效;发布正文中的字面量 `\\n` 归一化为真实换行。 +- 视频素材抓取跟进小红书 EF 系列视频分档兼容。 + +--- + # v5.7.1 (2026-09-15) ### 第三方插件 pin 升级(openclaw-weixin 2.4.8 / wecom-openclaw-cli 1.1.1) diff --git a/README.md b/README.md index da0411fb..21dbae45 100755 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - 微信公众号文章写作、排版与推送 - 小红书/小绿书图文创作与发布 - 图文海报生成 -- 短视频生成与多平台分发(支持视频号、抖音、小红书) +- 视频生成与多平台分发(支持视频号、抖音、小红书) - Twitter/X、微博、知乎等平台发文 - 微信朋友圈内容发布(通过企业微信接口) - 爆款视频追爆分析、仿写与再创作(支持抖音、B站和小红书视频链接) @@ -32,13 +32,15 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 --- -## 🚀 **v5.7.1 更新** +## 🚀 **V5.7.1~5.7.2 更新** - 小红书、抖音、视频号 DNA系统升级到2.0架构,Let's do this like an expert! - content producer 升级为专家系统,现在除了AIGC大片外,还可以复刻众多短视频平台流行的“套路”,更易获得平台推荐流量: > 效果展示,xiaobei的视频号:https://openclaw-for-business.com/xiaobei-wxchannel.jpg - xiaobei 可直接指挥content producer,用户可选择将brief出具、节点验收等委托xiaobei -- 修复一键安装脚本中,openclaw-weixin不会自动升级的问题 +- 新增抖音平台图文音乐内容发布能力,支持多图上传、选择推荐配乐与发布链接回收。 +- AIGC 端点支持阿里云百炼 Agent Plan:现在无需去多个平台开通不同账号,最简只用初始安装时的百炼账号就可获得全部能力。 +- 修复一键安装脚本openclaw-weixin不会自动升级的问题 详见 [CHANGELOG.md](CHANGELOG.md) @@ -48,11 +50,11 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 ### 0. 准备 API Key -推荐开通 [阿里云百炼「Token Plan」套餐](https://www.aliyun.com/benefit/ai/aistar?clubBiz=subTask..12766005..10274..)——一个套餐覆盖 DeepSeek-V4-Flash、GLM-5.2、Qwen3.6-Flash 等主流模型,**无月限额、不限购**,xiaobei 默认主力模型 DeepSeek-V4-Flash 即走此通道。开通后获得 `AWK_API_KEY`,主力模型、视觉模型、替补模型**一个 key 全覆盖**。 +推荐开通 [阿里云百炼「Token Plan」套餐](https://www.aliyun.com/benefit/ai/aistar?clubBiz=subTask..12766005..10274..)——一个套餐覆盖**思考与对话、图像生成、TTS 语音合成、ASR 语音识别和视频生成**全部大模型能力,无需为这些能力分别准备其他供应商账号或 Key。 > 💡 **套餐选择**:前期熟悉安装可选 **Lite 版 39 元/月**;正常使用建议 **Standard 版 139 元/月**。想继续使用火山CodePlan见下方 "模型费用说明" -> 🎬 **想用视频生成能力?** 开通百炼Token Plan后,会免费获得一定额度的 `happyhorse-1.1` ,只需把对应 key(`MODELSTUDIO_API_KEY`)配置到 `daemon.env`。 +> 🎬 **想用视频生成能力?** 默认可直接复用百炼 `AWK_API_KEY` 调用 `happyhorse` 系列,也可通过配置 `MODELSTUDIO_API_KEY` 和 `WORKSPACE_ID`使用百炼平台的赠送额度和“节省计划“包。 > 除了阿里云的`happyhorse`系列,我们现在也支持 minimax 的H3!详见下方[视频生成模型配置](#-视频生成模型配置) @@ -161,27 +163,28 @@ irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit. > > xiaobei 底层基于 openclaw,建议先准备好大模型 API: > -> - **主力模型(强烈推荐)**:[阿里云百炼「Token Plan」套餐](https://www.aliyun.com/benefit/ai/aistar?clubBiz=subTask..12766005..10274..) — 一个套餐覆盖 DeepSeek-V4-Flash、GLM-5.2、Qwen3.6-Flash 等主流模型,**无月限额、不限购**。前期熟悉安装可选 Lite 版 39 元/月,正常使用建议 Standard 版 139 元/月。开通后获得 `AWK_API_KEY`,xiaobei 默认主力模型 DeepSeek-V4-Flash 即走此通道。 +> - **主力模型(强烈推荐)**:[阿里云百炼「Token Plan」套餐](https://www.aliyun.com/benefit/ai/aistar?clubBiz=subTask..12766005..10274..) — 一个套餐已经可以覆盖xiaobei系统所需的所有大模型(思考与对话、图像生成、TTS 语音合成、ASR 语音识别和视频生成)。 > > - **仍想用火山方舟 Coding Plan 的用户**:在默认配置模板基础上参考 [openclaw-awk.json](config-templates/openclaw-awk.json),手动替换 `provider` 和 `agents.default` 字段即可。 > **🎬 视频生成模型配置** > -> AI 视频生成(`aigc-video-gen`,短视频制作与素材补充都会用到)需额外开通视频生成模型,并把对应 key 配置到 `daemon.env`(任选其一,百炼优先): +> AI 视频生成(`aigc-video-gen`,短视频制作与素材补充都会用到)默认复用百炼 `AWK_API_KEY`,走 Agent Plan 端点。也可按需配置百炼业务空间、火山或 MiniMax: > > | 平台 | 环境变量 | 模型 | > |------|---------|------| -> | 阿里云百炼(优先) | `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`) | `happyhorse-1.1-i2v` / `happyhorse-1.1-t2v` / `happyhorse-1.1-r2v` | +> | 阿里云百炼 Agent Plan(默认) | `AWK_API_KEY` | `happyhorse-1.1-i2v` / `happyhorse-1.1-t2v` / `happyhorse-1.1-r2v` | +> | 阿里云百炼业务空间(可选) | `WORKSPACE_ID` + `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`) | 同上 | > | 火山引擎方舟 | `AWK_GEN_KEY` | `doubao-seedance-2-0-fast-260128` / `doubao-seedance-2-0-260128` / `doubao-seedance-2-0-mini-260615` | > | minimax海螺 | `MINIMAX_API_KEY` | `minimax-H3` | > -> 若上述都没配则自动降级为 pexels/pixabay 免费素材模式(也得注册才能获得key,只不过是免费)。注意 `AWK_GEN_KEY` 与主力模型的 `AWK_API_KEY` 是一个 key,但必须在环境变量中以不同变量名称赋值,火山视频生成只认 `AWK_GEN_KEY`。申请成功后可以让小贝喊系统内置的IT Engineer帮你完成配置。 +> 只配置百炼 `AWK_API_KEY` 时自动走 Agent Plan;若已有其他视频凭据,自动选择顺序为 MiniMax → 火山 → 百炼业务空间 → 百炼 Agent Plan。均未配置时,小贝改用 pexels/pixabay 素材模式(仍需注册获取对应的免费 Key)。`AWK_GEN_KEY` 是火山视频生成凭据,与百炼 `AWK_API_KEY` 不可混用。需要调整配置时,可以让小贝调用内置 IT Engineer 协助。 > **🧠 进阶:记忆增强与 dream(可选)** > > 默认配置下,小贝的记忆走 FTS 全文检索,已经够用且零额外配置。如果你记忆体量很大、想要更好的语义召回,可以接入一个 embedding 模型;也可以选择打开凌晨"做梦"机制让小贝在夜间整理记忆。 > -> 推荐用 [SiliconFlow](https://cloud.siliconflow.cn/i/WNLYbBpi)(🎁 xiaobei 邀请链接,注册认证后你可获得一张 16 元代金券),它提供 `BAAI/bge-m3` 与 `Qwen/Qwen3-VL-Embedding` 系列,均为 OpenAI 接口格式,可直接配置为 `memorySearch` 的 embedding provider。配置方法:把 `agents.defaults.memorySearch.provider` 从 `"none"` 改为 `"openai-compatible"`,并补上 `remote.baseUrl` / `remote.apiKey` / `model`;想开做梦就把 `plugins.entries.memory-core.config.dreaming.enabled` 改回 `true`。改完重启 gateway 生效。可以让小贝帮你完成配置。 +> 任何 OpenAI 接口格式的 embedding 服务都可以接(如阿里云百炼的 `text-embedding-v3/v4` 系列)。配置方法:把 `agents.defaults.memorySearch.provider` 从 `"none"` 改为 `"openai-compatible"`,并补上 `remote.baseUrl` / `remote.apiKey` / `model`;想开做梦就把 `plugins.entries.memory-core.config.dreaming.enabled` 改回 `true`。改完重启 gateway 生效。可以让小贝帮你完成配置。 ### 配置繁琐,不想操心? @@ -274,7 +277,7 @@ v5.6.0 中我们几乎重构了 OpenClaw 原版的浏览器自动化方案(详 | `smart-search` | 智能搜索——绕开 openclaw 内置 web search 的 api key 依赖,零部署免费方案 | | `web-form-fill` | 网络表单填报——从信息搜集到浏览器填报的完整工作流,强制有头模式便于用户随时介入 | | `login-manager` | 平台登录态管理——5 平台统一有头手动登录、探活规则、中央 cookie+UA 存储约定 | -| 各平台发布/互动 skill | `twitter-post` / `twitter-interact` / `weibo-publish` / `zhihu-publish` / `xhs-publish` / `xhs-content-ops` / `douyin-publish` / `wechat-channels-publish` / `xianyu-ops` / `wx-mp-hunter` / `wx-mp-engagement` 等平台专属浏览器自动化技能 | +| 各平台发布/互动 skill | `twitter-post` / `twitter-interact` / `weibo-publish` / `zhihu-publish` / `xhs-publish` / `xhs-content-ops` / `douyin-video-publish` / `wechat-channels-publish` / `xianyu-ops` / `wx-mp-hunter` / `wx-mp-engagement` 等平台专属浏览器自动化技能 | 这些技能共享同一套 forked camoufox-cli + 持久化 session 机制,登录态在 session profile 里闭环,按场景分离有头/无头模式(登录+填报走有头,自动化操作走无头),靠 session 名字符串约定共享 profile 目录与登录态。 @@ -340,6 +343,7 @@ wiseflow/ - html-video(nexu-io 的 HTML 视频渲染方案 — `video-producer` 的 Stage 10 静帧→成片渲染思路与素材组装约定参考自此) https://github.com/nexu-io/html-video - ViMax(HKUDS 的视频生成框架 — `video-producer` 的机位一致性约束与素材 slot 规划借鉴其镜头规划策略) https://github.com/HKUDS/ViMax - OpenMontage(calesthio 的开源蒙太奇剪辑方案 — `video-producer` 的 Stage 12 拼接成片+转场工作流借鉴其片段组装与节奏控制思路) https://github.com/calesthio/OpenMontage +- gbro-collage-broll(MIT — 半调纸拼贴 B-roll 三闸门方法论 — `expert-video` 的 Collage B-roll workflow 移植自此:隐喻设计法、语义色场表、visual-spec schema、静帧/视频 QA 标准照搬,闸门映射为 GATE A/B、渲染栈换成 awk-img-gen + aigc-video-gen i2v) https://github.com/pyang5166/gbro-collage-broll - agent-skills-launch-pack_(起号方法论知识来源) https://github.com/chenjin-cmd/agent-skills-launch-pack_ ## Citation diff --git a/awada/src/audio-transcribe.ts b/awada/src/audio-transcribe.ts index a677cb90..698dc9ce 100644 --- a/awada/src/audio-transcribe.ts +++ b/awada/src/audio-transcribe.ts @@ -1,64 +1,247 @@ /** - * Audio transcription via SiliconFlow API. + * Audio transcription via 公共 ASR 路由(与 crews/main/skills/_shared/asr.py 同优先级)。 * - * Env vars: - * SILICONFLOW_API_KEY — API key (required) - * ASR_MODEL — model name (required, e.g. "FunAudioLLM/SenseVoiceSmall") + * 供应商优先级(2026-09 拍板,凭据在哪家走哪家): + * 1. 火山录音文件极速版 — VOLC_ASR_APP_ID+VOLC_ASR_ACCESS_KEY(旧控制台双头) + * 或 VOLC_ASR_APP_KEY(新控制台单头) + * POST https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash + * 2. 百炼业务空间 — WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY + * POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation + * 3. 百炼 agent plan — AWK_API_KEY + * POST https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation + * 模型 qwen-audio-3.0-asr-flash(BAILIAN_ASR_MODEL 可覆盖) * - * API: POST https://api.siliconflow.cn/v1/audio/transcriptions - * multipart/form-data { file, model } - * Response: { text: string } + * 某家失败自动落下一家;全部失败时 error 汇总各家原因。 + * 百炼以 base64 data URI 直传(编码后 ≤10MB,语音消息远低于此限)。 */ -const SILICONFLOW_ENDPOINT = "https://api.siliconflow.cn/v1/audio/transcriptions"; +const VOLC_ASR_ENDPOINT = + "https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash"; +const BAILIAN_WS_BASE_TEMPLATE = "https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1"; +const BAILIAN_AGENT_PLAN_BASE = "https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1"; +const BAILIAN_ASR_PATH = "/services/aigc/multimodal-generation/generation"; +const BAILIAN_DEFAULT_ASR_MODEL = "qwen-audio-3.0-asr-flash"; +const MAX_BAILIAN_B64_BYTES = 9 * 1024 * 1024; + +const AUDIO_MIME_BY_EXT: Record = { + mp3: "audio/mpeg", + wav: "audio/x-wav", + ogg: "audio/ogg", + opus: "audio/opus", + m4a: "audio/mp4", + aac: "audio/aac", + flac: "audio/flac", + amr: "audio/amr", +}; export type TranscribeResult = | { ok: true; text: string } | { ok: false; error: string }; -/** - * Transcribe an audio buffer using SiliconFlow's ASR API. - */ -export async function transcribeAudio( +interface BailianEndpoint { + base: string; + apiKey: string; + mode: "workspace" | "agent-plan"; +} + +function audioFormatHint(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + return ext in AUDIO_MIME_BY_EXT ? ext : "wav"; +} + +function listBailianEndpoints(): BailianEndpoint[] { + const endpoints: BailianEndpoint[] = []; + const wsid = process.env.WORKSPACE_ID?.trim(); + if (wsid) { + const key = + process.env.MODELSTUDIO_API_KEY?.trim() || + process.env.DASHSCOPE_API_KEY?.trim(); + if (key) { + endpoints.push({ + base: BAILIAN_WS_BASE_TEMPLATE.replace("{wsid}", wsid), + apiKey: key, + mode: "workspace", + }); + } + } + const awkKey = process.env.AWK_API_KEY?.trim(); + if (awkKey) { + endpoints.push({ base: BAILIAN_AGENT_PLAN_BASE, apiKey: awkKey, mode: "agent-plan" }); + } + return endpoints; +} + +async function transcribeVolc( audioBuffer: Buffer, fileName: string, ): Promise { - const apiKey = process.env.SILICONFLOW_API_KEY?.trim(); - if (!apiKey) { - return { ok: false, error: "SILICONFLOW_API_KEY not set" }; - } + const appId = process.env.VOLC_ASR_APP_ID?.trim(); + const accessKey = process.env.VOLC_ASR_ACCESS_KEY?.trim(); + const appKey = process.env.VOLC_ASR_APP_KEY?.trim(); + const resourceId = + process.env.VOLC_ASR_RESOURCE_ID?.trim() || "volc.bigasr.auc_turbo"; - const model = process.env.ASR_MODEL?.trim(); - if (!model) { - return { ok: false, error: "ASR_MODEL not set" }; + const headers: Record = { + "Content-Type": "application/json", + "X-Api-Resource-Id": resourceId, + "X-Api-Request-Id": crypto.randomUUID(), + "X-Api-Sequence": "-1", + }; + let uid: string; + if (appId && accessKey) { + headers["X-Api-App-Key"] = appId; + headers["X-Api-Access-Key"] = accessKey; + uid = appId; + } else if (appKey) { + headers["X-Api-Key"] = appKey; + uid = appKey; + } else { + return { ok: false, error: "火山 ASR 凭据未配置" }; } - const form = new FormData(); - form.append("file", new Blob([audioBuffer]), fileName); - form.append("model", model); + const body = { + user: { uid }, + audio: { + data: audioBuffer.toString("base64"), + format: audioFormatHint(fileName), + }, + request: { + model_name: "bigmodel", + show_utterances: false, + enable_itn: true, + enable_punc: true, + }, + }; try { - const res = await fetch(SILICONFLOW_ENDPOINT, { + const res = await fetch(VOLC_ASR_ENDPOINT, { method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: form, + headers, + body: JSON.stringify(body), }); + const status = res.headers.get("X-Api-Status-Code") ?? ""; + if (status !== "20000000") { + const msg = res.headers.get("X-Api-Message") ?? ""; + const snippet = (await res.text().catch(() => "")).slice(0, 200); + return { ok: false, error: `火山 ASR 失败 (status=${status}, msg=${msg}): ${snippet}` }; + } + const json = (await res.json()) as { result?: { text?: string } }; + const text = json.result?.text?.trim() ?? ""; + if (!text) { + return { ok: false, error: "火山 ASR 返回空文本" }; + } + return { ok: true, text }; + } catch (err) { + return { ok: false, error: `火山 ASR 请求失败: ${String(err)}` }; + } +} + +async function transcribeBailian( + audioBuffer: Buffer, + fileName: string, + endpoint: BailianEndpoint, +): Promise { + if (audioBuffer.length * (4 / 3) > MAX_BAILIAN_B64_BYTES) { + return { + ok: false, + error: `音频 ${audioBuffer.length} 字节超百炼 base64 上限(10MB)`, + }; + } + const fmt = audioFormatHint(fileName); + const mime = AUDIO_MIME_BY_EXT[fmt] ?? "audio/x-wav"; + const model = + process.env.BAILIAN_ASR_MODEL?.trim() || BAILIAN_DEFAULT_ASR_MODEL; + + const body = { + model, + input: { + messages: [ + { + role: "user", + content: [ + { + type: "input_audio", + input_audio: { + data: `data:${mime};base64,${audioBuffer.toString("base64")}`, + }, + }, + ], + }, + ], + }, + parameters: { format: fmt }, + }; + try { + const res = await fetch(`${endpoint.base}${BAILIAN_ASR_PATH}`, { + method: "POST", + headers: { + Authorization: `Bearer ${endpoint.apiKey}`, + "Content-Type": "application/json", + "X-DashScope-SSE": "disable", + }, + body: JSON.stringify(body), + }); if (!res.ok) { - const body = await res.text().catch(() => ""); - return { ok: false, error: `SiliconFlow API ${res.status}: ${body.slice(0, 200)}` }; + const snippet = (await res.text().catch(() => "")).slice(0, 200); + return { + ok: false, + error: `百炼 ASR 失败 (${endpoint.mode}, HTTP ${res.status}): ${snippet}`, + }; } - - const json = (await res.json()) as { text?: string }; - const text = json.text?.trim(); + const json = (await res.json()) as { output?: { text?: string } }; + const text = json.output?.text?.trim() ?? ""; if (!text) { - return { ok: false, error: "SiliconFlow returned empty transcript" }; + return { ok: false, error: `百炼 ASR 返回空文本 (${endpoint.mode})` }; } - return { ok: true, text }; } catch (err) { - return { ok: false, error: `SiliconFlow request failed: ${String(err)}` }; + return { ok: false, error: `百炼 ASR 请求失败 (${endpoint.mode}): ${String(err)}` }; + } +} + +function volcConfigured(): boolean { + const appId = process.env.VOLC_ASR_APP_ID?.trim(); + const accessKey = process.env.VOLC_ASR_ACCESS_KEY?.trim(); + const appKey = process.env.VOLC_ASR_APP_KEY?.trim(); + return Boolean((appId && accessKey) || appKey); +} + +/** + * Transcribe an audio buffer: 火山 → 百炼业务空间 → 百炼 agent plan 依次尝试。 + */ +export async function transcribeAudio( + audioBuffer: Buffer, + fileName: string, +): Promise { + const errors: string[] = []; + + if (volcConfigured()) { + const result = await transcribeVolc(audioBuffer, fileName); + if (result.ok) { + return result; + } + errors.push(`火山: ${result.error}`); + } + + for (const endpoint of listBailianEndpoints()) { + const result = await transcribeBailian(audioBuffer, fileName, endpoint); + if (result.ok) { + return result; + } + errors.push(`百炼(${endpoint.mode}): ${result.error}`); + } + + if (errors.length === 0) { + return { + ok: false, + error: + "ASR 凭证未配置:需 VOLC_ASR_APP_ID+VOLC_ASR_ACCESS_KEY 或 VOLC_ASR_APP_KEY(火山)," + + "或 WORKSPACE_ID+MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY(百炼业务空间)," + + "或 AWK_API_KEY(百炼 agent plan)", + }; } + return { ok: false, error: errors.join(" | ") }; } /** diff --git a/awada/src/message-handler.ts b/awada/src/message-handler.ts index 0e429001..015f921f 100644 --- a/awada/src/message-handler.ts +++ b/awada/src/message-handler.ts @@ -247,7 +247,7 @@ async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { conversation_id: meta.conversation_id, }); - // ---- Handle audio: transcribe via SiliconFlow, then treat as text ---- + // ---- Handle audio: transcribe via ASR 路由(火山→百炼),then treat as text ---- let audioTranscript = ""; for (const audio of audios) { const audioUrl = audio.file_url; diff --git a/crews/content-producer/AGENTS.md b/crews/content-producer/AGENTS.md index cd4ebd22..b0aaf82e 100644 --- a/crews/content-producer/AGENTS.md +++ b/crews/content-producer/AGENTS.md @@ -11,15 +11,15 @@ ## 能力方向路由 -**视频类铁律**:只要接的是视频制作活儿,`expert-video` 的**通用制作流程**(Stage 0→14 阶段链 + GATE A/B 两闸门 + 护栏 + 工作区与交付约定)**一律适用**——它是基准准则,不是"没匹配到类型时的备选",也不与类型 workflow 并列。下表匹配到的类型 workflow 只是叠加在基准上的进一步细化。 +**视频类铁律**:只要接的是视频制作活儿,`expert-video` 的**通用制作流程**(Stage 0→15 阶段链 + GATE A/B 两闸门 + 护栏 + 工作区与交付约定)**一律适用**——它是基准准则,不是"没匹配到类型时的备选",也不与类型 workflow 并列。类型 workflow 只负责其中 **script 生产(Brief→script→自检→GATE A 质检)** 的类型化指导,并附该类型的制作与验收约定——不接管、不裁掉流程本身。 | 入口信号 | 专家包 | 怎么做 | |---------|--------|--------| -| Brief 指定 `workflow`(如 `reversal-ad`) | `expert-video` | 通用制作流程 + 读 Brief 指定的 `workflows/<值>.md`,按其阶段裁剪执行 | -| 影视解说 / 剧情解说 + 突然反转插入品宣("万万没想到"式) | `expert-video` | 通用制作流程 + Reversal Ad 细化 | -| 甲方交付口播文案或真人口播录音,要合成声画 | `expert-video` | 通用制作流程 + Narration Video 细化 | -| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | `expert-video` | 通用制作流程 + Collage B-roll 细化 | -| "从零做视频""出一支完整视频""按这个主题拍片子"(无匹配类型) | `expert-video` | 只按通用制作流程走,Stage 1 `intent-router` 定档位(narrative / motion / montage) | +| Brief 指定 `workflow`(如 `reversal-ad`) | `expert-video` | 通用制作流程 + 读 Brief 指定的 `workflows/<值>.md`,按它生产 script 并按它自检 | +| 影视解说 / 剧情解说 + 突然反转插入品宣("万万没想到"式) | `expert-video` | 通用制作流程 + 按 reversal-ad 生产 script 与自检 | +| 口播(真人出镜 / 数字人 / 真人录音,甲方出口播稿)或旁白(TTS 配音解说,稿由 CP 写)要合成声画 | `expert-video` | 通用制作流程 + 按 narration-video 生产 script 与自检 | +| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | `expert-video` | 通用制作流程 + 按 collage-broll 生产 script(隐喻清单)与自检 | +| "从零做视频""出一支完整视频""按这个主题拍片子"(无匹配类型) | `expert-video` | 只按通用制作流程走(叙事 / 动效 / 蒙太奇手法由我据创意自定);Brief 缺失或创意不清时先走 `story-develop` intake workflow 与甲方收敛 Brief,再进 Stage 1 `script-write` | | 已有素材要剪辑、修整、拼接、配音、烧字幕 | `expert-video` | 通用制作流程的 Stage 12 工具箱(只做几何级修整) | | "做网页/落地页/APP 界面/品牌视觉体系" | `expert-design` | Web Page / App UI / Brand Visual | @@ -35,7 +35,7 @@ - **每接到一个活儿先自建工作区**:视频类走 `output_videos//`,平面设计类走 `design_assets/YYYY-MM-DD-<任务名>/`(由 `design-full init` 建)。甲方传入的现成目录只作素材来源,不当自己的工作区。 - **Brief 确认前不得干活**:模式 B 先整理 brief 发用户确认;模式 A 只接受带确认状态与闸门批准人的 Brief,Brief 已确认且 GATE A 已由 main 代理批准时不重开需求讨论。 - **成品交付前必跑自检**:视频走公共 `video-review` + 响度归一化(`video-producer normalize`,-14 LUFS 必跑),平面设计走视觉 review(对照 brief + DESIGN.md)。 -- **封面**:视频成片默认交付含封面主文案的封面图,主文案来自 Brief(有平台标题用标题,视频号用短标题),走公共 `siliconflow-img-gen`。 +- **封面**:视频成片默认交付含封面主文案的封面图,主文案来自 Brief(有平台标题用标题,视频号用短标题),走公共 `awk-img-gen`。 - **不许声称没做过的事**:没有 tool result 或产物文件证明,不许声称已生成/已渲染/已改动。 - **平台运营不在 CP**:发布到抖音/视频号/小红书/B站等归 main agent 的各平台专家包,CP 不碰;也不私信用户、不代拟运营话术。 - **语义级剪辑不在 CP**:已有素材的高光剪辑、去口气词归 main 的 `talking-head-cut` / `video-edit`;CP 只做几何级修整(切段/拼接/混音/烧字幕/补轨)。 diff --git a/crews/content-producer/openclaw_setting_sample.json b/crews/content-producer/openclaw_setting_sample.json index 2c6e1033..49abd09b 100644 --- a/crews/content-producer/openclaw_setting_sample.json +++ b/crews/content-producer/openclaw_setting_sample.json @@ -16,7 +16,7 @@ "browser-guide", "pexels-footage", "pixabay-footage", - "siliconflow-img-gen", + "awk-img-gen", "smart-search", "video-review", "wxwork-drive", diff --git a/crews/content-producer/skills/expert-design/SKILL.md b/crews/content-producer/skills/expert-design/SKILL.md index 56405b07..ac4e0f88 100644 --- a/crews/content-producer/skills/expert-design/SKILL.md +++ b/crews/content-producer/skills/expert-design/SKILL.md @@ -45,7 +45,7 @@ metadata: 三条 workflow 共用下方 Step 1/2/3/6/7 骨架,差异只在 brief 必含字段与 Step 4/5。 -不适用:视频 / 动画 / 封面图 → `expert-video`(封面走其 Stage 14a);单张配图生成 → 公共 `siliconflow-img-gen`。 +不适用:视频 / 动画 / 封面图 → `expert-video`(封面走其 Stage 14a);单张配图生成 → 公共 `awk-img-gen`。 ## 工具清单 @@ -53,7 +53,7 @@ metadata: |------|------|------| | `design-full` | 建任务工作区 + brief 模板;从内置 14+ 套设计系统库匹配风格 | `design-full init <任务名>` / `design-full pick "<风格描述>"` | -跨领域公共技能:`pexels-footage` / `pixabay-footage`(配图与背景图首选)、`siliconflow-img-gen`(配图备选)、`smart-search`(参考站点调研)。 +跨领域公共技能:`pexels-footage` / `pixabay-footage`(配图与背景图首选)、`awk-img-gen`(配图备选)、`smart-search`(参考站点调研)。 ## 通用骨架(七步,两闸门) @@ -93,7 +93,7 @@ design-full pick "<风格描述>" ### Step 4:素材获取 - **优先**:公共 `pexels-footage` / `pixabay-footage` 搜索下载 -- **备选**:公共 `siliconflow-img-gen` 生成(参数记 `prompts.json`) +- **备选**:公共 `awk-img-gen` 生成(参数记 `prompts.json`) - 甲方给的素材入 `source/`,记录来源与授权 ### Step 5:HTML + CSS 编写 diff --git a/crews/content-producer/skills/expert-design/workflows/web-page.md b/crews/content-producer/skills/expert-design/workflows/web-page.md index 427ffa90..efe889f7 100644 --- a/crews/content-producer/skills/expert-design/workflows/web-page.md +++ b/crews/content-producer/skills/expert-design/workflows/web-page.md @@ -14,7 +14,7 @@ ## Step 4:素材 -页面所需配图 / 背景图 / 参考图:`pexels-footage` / `pixabay-footage` 优先,`siliconflow-img-gen` 备选,全部落 `source/` 并记 `prompts.json`。 +页面所需配图 / 背景图 / 参考图:`pexels-footage` / `pixabay-footage` 优先,`awk-img-gen` 备选,全部落 `source/` 并记 `prompts.json`。 ## Step 5:编写 diff --git a/crews/content-producer/skills/expert-video/SKILL.md b/crews/content-producer/skills/expert-video/SKILL.md index 8e737e77..51938ad5 100644 --- a/crews/content-producer/skills/expert-video/SKILL.md +++ b/crews/content-producer/skills/expert-video/SKILL.md @@ -32,7 +32,7 @@ metadata: 1. **先明确 Brief**:至少问清做什么类型视频、给谁看、要传达什么、时长与横竖屏、有没有现成素材、要不要口播(谁的声音)、什么时候要;整理成 `brief.md` 发用户确认。模糊想法("做个短片""帮我策划一下")**不算确认**,不得据此调渲染类工具。 2. **素材必须落实位置**:让用户给出**绝对路径**(或明确授权从哪个目录取),逐条 `ls` 确认真实存在、可解码;缺什么明说,不许拿"待补"开工。 -3. **口播内容**:口播文案由甲方出(模式 A 是 main agent,模式 B 是用户)。用户只给大意时我可代拟,但必须发用户确认定稿;明确是用户真人口播时,必须拿到录音文件(绝对路径)。 +3. **口播与旁白分开**:**口播**(真人出镜 / 数字人 / 真人录音)的口播稿由甲方出(模式 A 是 main agent,模式 B 是用户)——模式 B 用户只给大意时可代拟,但必须发用户确认定稿;真人口播必须拿到录音文件(绝对路径)。**旁白**(剪辑配的解说)完全由我写,GATE A 交审,不找甲方要旁白稿。 ### 甲方交付什么、我交付什么 @@ -40,7 +40,7 @@ metadata: |----------|------| | `brief.md` | 绝对路径。含视频类型 / workflow、主题与观看理由、核心传达、业务植入与 CTA(植入位置与方式、内容与业务的衔接句要求、CTA 主目标与句式)、时长与横竖屏、素材清单、封面要求、交付与验收、闸门批准人。**不含 DNA 信息**(甲方内部资产,我不读也不用) | | 已有素材 | 绝对路径逐条列出,含来源与授权说明;我只做入库校验与技术处理 | -| 口播文案 / 录音 | 有口播时甲方出具 `voiceover.md`(绝对路径);我不重写策略文案,只做声画实现。真人口播时给录音文件绝对路径 | +| 口播文案 / 录音 | **口播**(真人出镜 / 数字人 / 真人录音)时甲方出具 `voiceover.md`(绝对路径)或录音文件,我不重写、只做声画实现;**旁白**(剪辑配解说)不由甲方出,由我写 | - ✅ 缺字段 → 向 Brief owner 澄清后再开工。 - ❌ 缺字段 → 自己猜品牌卖点、自己编授权、自己改需求方向。 @@ -55,25 +55,27 @@ metadata: | 层 | 是什么 | 怎么用 | |----|--------|--------| -| **通用制作流程**(本文下方) | 我做**任何**视频制作工作都必须遵循的准则:Stage 0→14 阶段链、GATE A / GATE B 两闸门、返工与耗时上限、决策审计链、工作区与交付约定 | 永远适用,不因视频类型而跳过或替换 | -| **类型 workflow**(`workflows/<值>.md`) | 在通用制作流程**之上**对某一类视频的进一步细化与明确化:阶段裁剪、叙事套路约束、声音 / 画面规范、验收补充 | Brief 指定 `workflow` 时必读必用,按其细化执行;细化内容与通用流程冲突时以 workflow 为准,但**闸门与护栏不让步** | +| **通用制作流程**(本文下方) | 我做**任何**视频制作工作都必须遵循的准则:Stage 0→15 阶段链、GATE A / GATE B 两闸门、返工与耗时上限、决策审计链、工作区与交付约定 | 永远适用,不因视频类型而跳过或替换 | +| **workflow**(`workflows/*.md`) | 两类——**intake 类**(`story-develop`:没有 Brief 或 Brief 不清晰时与甲方探讨收敛 Brief,**不是 `Brief.workflow` 取值**);**type 类**(`narration-video` / `collage-broll` / `reversal-ad`:**指导从 Brief 生产该类型的 script,含这一步之后的自检与 GATE A 质检标准**;并附该类型的制作与验收约定,GATE A 批准后按通用流程执行时适用) | type 类:Brief 指定 `workflow` 时必读必用,按它生产 script、按它自检;intake 类:Brief 缺失或创意不足以直接写剧本时触发 | | **工具说明**(`tools/<工具>/SKILL.md`) | 每个子命令的入参、产物路径、退出码与旁路条件 | 调用前查;本文不重复参数细节 | -> 通用制作流程**不是**与类型 workflow 并列的第四条路,也**不是**"Brief 没指定类型时的 fallback"。它是底座;类型 workflow 只在底座上细化,产出特定类型的视频。 +> 通用制作流程**不是**与类型 workflow 并列的一条路,也**不是**"Brief 没指定类型时的 fallback"。它是我做任何视频都必走的全程流程;类型 workflow 只负责其中 **Stage 1–2(Brief→script→自检)** 这一段的类型化指导,并附该类型的制作约定——不接管、不裁掉流程本身。 命名约定:Workflow 名与 Tool 名是包内**逻辑资源名**,不是 Workspace 路径,不要拼成相对路径执行;只有工具清单里列出的 wrapper 名能直接当 shell 命令调用。`output_videos/`、`design_assets/` 才是 Workspace 相对路径(从 Content Producer workspace 根解析)。 -## 类型 workflow(细化层) +## 类型 workflow(script 生产指南) -| 视频类型 / 入口信号 | workflow | Brief `workflow` 值 | 它细化了什么 | +| 视频类型 / 入口信号 | workflow | Brief `workflow` 值 | 它指导什么 | |--------------------|----------|---------------------|--------------| | 影视解说 / 剧情解说 + 突然反转插入品宣("万万没想到"式) | Reversal Ad | `reversal-ad` | 三段结构占比、反转点落在 55%–76%、四种反转手法、反转幅度与接入丝滑度两轴(含二次反转 CTA)、素材三模式 sourcing(Blender 片库 / 用户直供三查 / AIGC)、意象桥与钩连句、植入段约束 | -| 口播类(甲方交付口播文案或真人录音,要合成声画) | Narration Video | `narration-video` | 口播稿落稿锁定不重写、按字级时间戳配画面、声音规范与验收清单 | -| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | Collage B-roll | `collage-broll` | 一句文稿 → 一个视觉隐喻 → 静帧 → i2v,三道闸门与 Gate 3 批量调度 | +| 口播 / 旁白类(口播:真人出镜、数字人、真人录音;旁白:TTS 配音解说) | Narration Video | `narration-video` | 口播稿落稿锁定不重写、旁白稿由我写 GATE A 交审、按字级时间戳配画面、声音规范与验收清单 | +| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | Collage B-roll | `collage-broll` | 隐喻清单即 script(GATE A 检)→ 静帧即素材(GATE B 检 contact sheet)→ Stage 10 `collage-broll render` 批量 i2v 组装;阶段裁剪表见 workflow | -- Brief 指定了 `workflow`:**先读对应文档并直接采用**,不得替换成自创流程。 -- Brief 未指定:仍走通用制作流程,由 Stage 1 `intent-router` 定档位——故事讲述型 narrative / 纯画面动效型 motion / 蒙太奇剪接型 montage。 -- 已有素材只要剪辑、修整、拼接、配音、烧字幕:仍走通用制作流程,中间阶段按实际裁剪,重心落在 Stage 12 工具箱(只做几何级修整;语义级高光剪辑归甲方 main)。**活儿小也不跳过** Stage 0 Brief 确认、Stage 13 自检与响度归一化、Stage 14 交付三件套。 +- Brief 指定了 `workflow`:**先读对应文档,按它生产 script 并按它自检**(GATE A 质检标准同此),不得替换成自创流程;其制作与验收约定(阶段裁剪、素材 sourcing 等)在该类型视频上生效。 +- Brief 未指定 `workflow`:仍走通用制作流程,叙事 / 动效 / 蒙太奇的处理手法由我据创意自定并记 `decisions.json`;Brief 创意不足以直接写剧本时,先走 `story-develop` intake workflow 与甲方收敛 Brief,再进 Stage 1 `script-write`。 +- 已有素材只要剪辑、修整、拼接、配音、烧字幕:仍走通用制作流程,中间阶段按实际裁剪,重心落在 Stage 12 工具箱(只做几何级修整;语义级高光剪辑归甲方 main)。 + +> `story-develop` 是 **intake 类 workflow**(Stage 0 创意澄清,**不是 `Brief.workflow` 取值**),与上表 type 类 workflow 正交:甲方没给 Brief 或 Brief 创意不足以直接写剧本时走它收敛出 Brief,再按通用制作流程 + 对应 type workflow 执行。详见 `workflows/story-develop.md`。 不属于我的活(交回甲方或转其他专家包): @@ -91,79 +93,71 @@ output_videos// # ├── brief.md # 甲方交付(拷贝入档)或 Stage 0 与用户定稿 ├── voiceover.md # 甲方交付的口播文案(如有) ├── reference/ # 可选:甲方给的参考拆解报告与差异化概念 -├── script/ # intent.json(1) / story.md(2) / script.md(3) / self-eval.json(3b) / decisions.json(审计链) -├── storyboard/ # storyboard.json(4) / shot_decompose.json(5) -├── characters/ # registry.json(6) + /{front,side,back}.png +├── script/ # script.md(1) / self-eval.json(2) / decisions.json(审计链) +├── storyboard/ # storyboard.json(3) / shot_decompose.json(4) +├── characters/ # registry.json(5) + /{front,side,back}.png ├── gates/ # gate-a.md / gate-b.md(含批准人与批准范围) ├── raw_materials/ # 甲方素材入库副本 + 授权记录 -├── slots/ # slot-plan.json(7) / asset-resolve.json(8) / slideshow-risk.json(9a) / delivery-promise.json(9b) +├── slots/ # slot-plan.json(6) / asset-resolve.json(7) / slideshow-risk.json(8) / delivery-promise.json(9) ├── render/shot-NN/ # (10) first-frame.png / last-frame.png / shot.mp4 ├── audio/ # narration.mp3 / narration-segments.json / bgm.mp3 / subtitles.srt ├── artifacts/ # (12) 按镜顺序的最终段 01_*.mp4 … NN_*.mp4 ├── video.mp4 # (12) 成片 ├── review/ # verdict.json(13a) / frames/ / motion-audit.json(13b) ├── cover.jpg # (14a) -└── final-deliver.md # (14b) +└── final-deliver.md # (15) ``` workflow 文档在技能包内,不是项目目录内容;项目目录只放 Brief、素材、脚本、渲染与交付产物。 -## 通用制作流程(Stage 0→14,两闸门) +## 通用制作流程(Stage 0→15,两闸门) -**我做任何视频都走这条链**;类型 workflow 只在此基础上裁剪与细化。每段的子命令是 `video-producer` 工具下的一个独立脚本,按流程逐个调。 +**我做任何视频都走这条链**;类型 workflow 只指导其中 **Stage 1–2(Brief→script→自检)** 的类型化生产,并附该类型的制作约定,不裁掉流程本身。每段的子命令是 `video-producer` 工具下的一个独立脚本,按流程逐个调。 ``` -Stage 0 Brief 确认 模式 A:读甲方 Brief,核对字段,缺口向 Brief owner 澄清 - 模式 B:用户未给 Brief 时引导讨论 → 代拟 brief.md → 发用户确认 - (两种模式的 Stage 0 都是"先把 Brief 定下来",无子命令) -Stage 1 intent-router 定档位(Brief 指定 workflow 时按该 workflow 的约束校验,未给定时从下面三个档位选一个) - narrative 故事讲述型(重情节、有人物弧光、含旁白,默认 3–5 镜/场) - motion 纯画面动效型(重节奏与视觉冲击、少对白,默认 5–8 镜快切) - montage 蒙太奇剪接型(重氛围、抽象、纯视觉,默认 4–7 镜无叙事) -Stage 2 story-develop idea → 故事(受众/类型显式复述、100–200 词梗概、人物、分场) - 甲方已交付口播文案时跳过:叙事以口播稿为准,不另起故事 -Stage 3 script-write 故事 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) - 甲方已交付口播文案时改为落稿锁定:原样落 script/script.md,不重写策略文案 -Stage 3b script-self-eval 脚本自评 N 维打分,任一维 <3 必返工(落稿锁定时只检查不改写) -Stage 4 storyboard-build 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) -Stage 5 shot-decompose 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) -Stage 6 character-register 角色三视图 front/side/back + static/dynamic features 拆分 +Stage 0 Brief intake 读甲方 Brief,核对字段,缺口向 Brief owner 澄清; + 甲方未给 Brief 或 Brief 不足以直接写剧本时 → 走 story-develop intake workflow(workflows/story-develop.md) +Stage 1 script-write Brief 创意 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) + 基线生产从此开始。 +Stage 2 script-self-eval 脚本自评 N 维打分,任一维 <3 必返工(落稿锁定时只检查不改写) +Stage 3 storyboard-build 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) +Stage 4 shot-decompose 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) +Stage 5 character-register 角色三视图 front/side/back + static/dynamic features 拆分 ────── GATE A:文本闸门(脚本+分镜+机位+角色全齐,停,发甲方审)────── -Stage 7 slot-plan 素材 slot 规划(template + hero slot + tone→slot 数) -Stage 8 asset-resolve 按 slot 取素材(Fast path:多源并发搜 + 缩略图人核 + rejected_picks 落盘) +Stage 6 slot-plan 素材 slot 规划(template + hero slot + tone→slot 数) +Stage 7 asset-resolve 按 slot 取素材(Fast path:多源并发搜 + 缩略图人核 + rejected_picks 落盘) 甲方已给素材时:先入库校验(可解码、分辨率/帧率/时长/音轨、授权记录),缺口才补搜 -Stage 9a slideshow-risk 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail 不许进 compose) -Stage 9b delivery-promise-lock 交付承诺八类锁定 + motion_ratio 预估 +Stage 8 slideshow-risk 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail 不许进 compose) +Stage 9 delivery-promise-lock 交付承诺八类锁定 + motion_ratio 预估 ────── GATE B:素材闸门(素材齐+计划过审,停,发甲方看 contact sheet)────── -Stage 10 render-shot 按 slot 渲染(AIGC 走 aigc-video-gen i2v 首尾帧插值;静图走 siliconflow-img-gen) +Stage 10 render-shot 按 slot 渲染(AIGC 走 aigc-video-gen i2v 首尾帧插值;静图走 awk-img-gen) motion-graphics Stage 10 第二条渲染路径:程序化逐帧动态图形(声明式 spec,产品段动效/标题动画/ 录屏圈选;确定性渲染不走 AIGC,与 render-shot 并列按镜头性质二选一) Stage 11 mix-audio 配音配乐四场景分流(A 人物对话声画同出 / B 旁白一次性 TTS 带字级时间戳 + 对齐 / C BGM 成片后统一生成(优先 bgm-library 免版税曲库,pexels/pixabay 并列;定制风格用 aigc-video-gen music)/ D 甲方口播录音 → ASR 时间戳 → 按时间戳补素材) - narration-layout 逐句 TTS 模式(每句独立 mp3):对齐镜头起点 + 防重叠守卫 + 越界断言 + SRT + 可选混音; + narration-layout 逐句 TTS 模式(每句独立 mp3):素材前后各留1s气口 + 防重叠守卫 + 越界断言 + 可选混音; 整段模式的时间戳对齐仍走 narration-align,两者互补 Stage 12 assemble 按序拼接成片(原子工具箱,见下节,我按场景组合,不写死流程) Stage 13a video-review 公共 video-review 技术自检(强制闸门,verdict=pass 才继续) Stage 13b motion-audit motion_led 抽查(兑付 delivery-promise) Stage 13c normalize 响度归一化到 -14 LUFS(**必跑**:`video-producer normalize`) -Stage 14a make-cover 封面(siliconflow-img-gen,必含封面主文案) -Stage 14b 交付 回报成片 + 封面 + final-deliver.md 的绝对路径与关键参数 +Stage 14a make-cover 封面(awk-img-gen,必含封面主文案) +Stage 15 交付 回报成片 + 封面 + final-deliver.md 的绝对路径与关键参数 ``` - **产物文件存在性即 checkpoint**:子命令先查产物文件是否存在,存在则 load 不重生成(允许手改 JSON 后续跑);要改哪段就重跑对应子命令,未改的不会重生成。 -- Stage 0–6 全是**文本产物**,付费生成前必停——GATE A 落在这条边界上;GATE B 落在素材就绪、pre-compose 闸门通过后,确认渲染前最终计划。 -- 类型 workflow 指定时,阶段裁剪以该 workflow 文档为准;**闸门位置与"停下发甲方"的纪律不变**。甲方已在 Brief 中代理批准某道闸门时,把批准范围落 `gates/` 后继续。 +- 类型 workflow 附带的制作约定(阶段裁剪、素材 sourcing、验收清单)在该类型视频上生效;**闸门位置与"停下发甲方"的纪律不变**。甲方已在 Brief 中代理批准某道闸门时,把批准范围落 `gates/` 后继续。 - 可选工具 `reference-concepts`:甲方给了参考视频拆解报告时,据报告出 2–3 个差异化概念落 `reference/concepts.md`。 ## 闸门与护栏 -### GATE A(Stage 6 后):文本闸门 +### GATE A(Stage 5 后):文本闸门 文本产物全齐(脚本 + 分镜 + 机位 + 角色),**停下发甲方审**: -- 呈交摘要:档位或 workflow、场次数、镜数、角色数、关键决策(路径 / 模型 / 风格选择的备选 + 置信度 + 理由) -- **结束本轮回复**,不许在同条回复里进 Stage 7 +- 呈交摘要:workflow 或创意定位、场次数、镜数、角色数、关键决策(路径 / 模型 / 风格选择的备选 + 置信度 + 理由) +- **结束本轮回复**,不许在同条回复里进 Stage 6 - 批准人是 Brief owner(模式 A = main agent,模式 B = 用户);甲方已在 Brief 中代理批准时,把批准范围落 `gates/gate-a.md` 后继续 - 批准是**逐闸门的**——早先的一句"你继续"不覆盖本闸门 @@ -182,6 +176,15 @@ Stage 14b 交付 回报成片 + 封面 + final-deliver.md 的绝对 - 技术故障(缺 key、依赖缺失、渲染报错)按 Dispatch Protocol spawn IT engineer,不静默卡死 - **决策审计链**:每个选择(路径 / 模型 / 风格 / 音色 / 任何 fallback)记 `备选 + 置信度 + 理由`,跨阶段累积进 `script/decisions.json` +### 改片(定向修改)约定 + +甲方拿着已交付成片提修改(改画面、补元素、换段重做等)时,委托会以**成片修改单**格式到达(v1 路径 / 修改点 / 不动范围 / 验收 / 回滚要求): + +- **定向重做,不推倒重建**:只重跑受影响的段,未涉及段沿用 v1 已终审产物;修改范围外的旁白 / 字幕 / BGM 时间轴零改动。 +- **保留上一版(强制)**:v1 成片先备份为 `artifacts/video_v1_backup.mp4`(vN 同理 `video_vN_backup.mp4`),v1/v2 并存,不覆盖交付历史。 +- 交付说明(`final-deliver.md`)增补**「修改记录」段**:修改点逐条、重做了哪些段、自检结果(video-review / motion-audit / normalize 照常跑)。 +- 修改单缺项(没写不动范围 / 没写验收)时先向甲方澄清,不自猜边界。 + ## Stage 12 工具箱(场景化组合,不写死顺序) 原子子命令(`clip-trim` / `audio-mix` / `timeline-compose` / `scene-compose` / `assemble` / `add-silent-audio` / `make-outro`)的入参与产物见 `video-producer` 工具说明。下面只给组合套路: @@ -201,12 +204,12 @@ Stage 14b 交付 回报成片 + 封面 + final-deliver.md 的绝对 | 工具 | 用途 | 命令 | |------|------|------| -| `video-producer` | 阶段链全部原子能力(意图路由、故事 / 剧本 / 分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面)+ 后期处理(`normalize` **必跑**、`burn-srt` / `duck` / `denoise` / `interp` 可选,全部干湿分离不覆盖输入) | `video-producer <子命令>`;`video-producer help` 列全量 | -| `collage-broll` | 纸拼贴 B-roll 的环境自检与 Gate 3 批量 i2v 调度(0 全通 / 1 参数错 / 2 部分失败,只重跑失败条目) | `collage-broll check-setup` / `collage-broll gate3 --batch [--dry-run]` | +| `video-producer` | 阶段链全部原子能力(剧本 / 分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面)+ 后期处理(`normalize` **必跑**、`burn-srt` / `duck` / `denoise` / `interp` 可选,全部干湿分离不覆盖输入) | `video-producer <子命令>`;`video-producer help` 列全量 | +| `collage-broll` | 纸拼贴 B-roll 的环境自检与 Stage 10 批量 i2v 调度(0 全通 / 1 参数错 / 2 部分失败,只重跑失败条目) | `collage-broll check-setup` / `collage-broll render --batch [--dry-run]` | -跨领域公共技能:`aigc-video-gen`(视频片段生成 / i2v 首尾帧插值,Stage 8/10;输出路径须落在 `output_videos/` 下,调用时 workdir 是 Content Producer workspace 根)、`siliconflow-img-gen`(静帧、角色三视图、封面,Stage 6/10/14a)、`awk-tts`(旁白 TTS,带字级时间戳,Stage 11B;`--enable-subtitle` 让火山流式 HTTP 原生返回时间戳)、`bgm-library`(ccMixter 免版税 + 自动 TASL 署名,商用安全,Stage 11C 优先)、`pexels-footage` / `pixabay-footage`(免版税素材与 BGM 搜索)、`video-review`(成片技术自检闸门,Stage 13a)、`video-edit subtitles`(main crew 暴露的烧字幕原子;不可用时向 Brief owner 报工具缺口,不手写 ffmpeg)。 +跨领域公共技能:`aigc-video-gen`(视频片段生成 / i2v 首尾帧插值,Stage 7/10;输出路径须落在 `output_videos/` 下,调用时 workdir 是 Content Producer workspace 根)、`awk-img-gen`(静帧、角色三视图、封面,Stage 5/10/14a)、`awk-tts`(旁白 TTS,带字级时间戳,Stage 11B;多供应商路由 火山→百炼,`--enable-subtitle` 两家都出字级时间戳)、`bgm-library`(ccMixter 免版税 + 自动 TASL 署名,商用安全,Stage 11C 优先)、`pexels-footage` / `pixabay-footage`(免版税素材与 BGM 搜索)、`video-review`(成片技术自检闸门,Stage 13a)、`video-edit subtitles`(main crew 暴露的烧字幕原子;不可用时向 Brief owner 报工具缺口,不手写 ffmpeg)。 -env 依赖:`AWK_API_KEY`(静帧 / 视频生成)、`VOLC_ASR_*`(`narration-align` 回退路径与甲方口播录音转写;旧控制台双头 `VOLC_ASR_APP_ID` + `VOLC_ASR_ACCESS_KEY`,或新控制台单头 `VOLC_ASR_APP_KEY`)。缺 env 时子命令 exit 2,补齐属 IT engineer 职责,不要静默降级。Python 依赖 `requests`、`Pillow`(`motion-graphics` 逐帧绘制)在仓根 `requirements.txt`。系统依赖:`motion-graphics` 需要 Noto Sans SC/CJK 字体(探测 `/usr/share/fonts/opentype/noto-sc` 等候选目录,缺失 exit 2;可用 spec `font_dir` 或 env `MG_FONT_DIR` 覆盖)。机器资源约束(线程数、分辨率上限、低载编码)读本 workspace `MEMORY.md` 或 Brief 的环境约束,不写死在技能包里(`motion-graphics` 默认即低载:nice19/veryfast/crf18/threads2)。 +env 依赖:`AWK_API_KEY`(agent plan 生图/视频/TTS/ASR 兜底)、`WORKSPACE_ID`+`MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY`(百炼业务空间,优先)、`VOLC_ASR_*`(`narration-align` 回退路径与甲方口播录音转写的火山优先路由;旧控制台双头 `VOLC_ASR_APP_ID` + `VOLC_ASR_ACCESS_KEY`,或新控制台单头 `VOLC_ASR_APP_KEY`)。ASR/TTS 凭据任一组在即可路由;全缺时子命令 exit 2,补齐属 IT engineer 职责,不要静默降级。Python 依赖 `requests`、`Pillow`(`motion-graphics` 逐帧绘制)在仓根 `requirements.txt`。系统依赖:`motion-graphics` 需要 Noto Sans SC/CJK 字体(探测 `/usr/share/fonts/opentype/noto-sc` 等候选目录,缺失 exit 2;可用 spec `font_dir` 或 env `MG_FONT_DIR` 覆盖)。机器资源约束(线程数、分辨率上限、低载编码)读本 workspace `MEMORY.md` 或 Brief 的环境约束,不写死在技能包里(`motion-graphics` 默认即低载:nice19/veryfast/crf18/threads2)。 ## 禁止事项(强制) @@ -215,9 +218,7 @@ env 依赖:`AWK_API_KEY`(静帧 / 视频生成)、`VOLC_ASR_*`(`narratio - **禁止声称没做过的事**:没有 tool result 或产物文件证明,不许声称已渲染 / 已生成 / 已改动。 - **禁止替甲方做需求决策**:选题方向、品牌事实、卖点承诺、业务植入与 CTA 口径、发布文案不由我定;Brief 没写就问。 - **禁止让甲方建工作区**:工作区自建;也不要把中间产物写进甲方(main / 用户)的目录。 -- **禁止把模糊想法擅自扩成多场多镜**:默认 1 场 3–5 镜,甲方要扩才扩。 - **禁止直接写 ffmpeg 命令**:所有 ffmpeg 调用走 `video-producer` / `collage-broll` 子命令或公共技能子命令;唯一例外是 workflow 文档里给出的既定 ffmpeg 模板(如 Collage B-roll 的首尾帧处理与 contact sheet 拼图),照抄执行不自创。 - **禁止自己做视频下载 / 转写 / 抽帧**:那是 main 的 `viral-chaser` 的活。 - **禁止引入 CLIP / torch 系本地模型**:素材匹配走 Fast path 人核缩略图。 -- **禁止扩充图库源**:保 Pexels + Pixabay 两源。 - **禁止批量生成撞运气**:逐条精做。 diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md b/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md index a4383138..34e48b49 100644 --- a/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md @@ -1,11 +1,11 @@ --- name: collage-broll -description: 纸拼贴 B-roll 的原子工具——环境自检与 Gate 3 批量 i2v 视频生成调度。 +description: 纸拼贴 B-roll 的原子工具——环境自检与 Stage 10 批量 i2v 视频生成调度。 --- # collage-broll — 工具说明 -> 本文是 `expert-video` 专家包内的工具说明书,不独立出现在技能列表中。制作流程(视觉隐喻 → 静帧 → 视频三道闸门)由包内 Collage B-roll Workflow 编排。 +> 本文是 `expert-video` 专家包内的工具说明书,不独立出现在技能列表中。制作流程(GATE A 隐喻清单 → GATE B 静帧 → Stage 10 i2v 组装)由包内 Collage B-roll Workflow 编排。 **用途**:代理纸拼贴 B-roll 的两个脚本,让 agent 走 PATH 调用、零路径拼接。 @@ -14,19 +14,19 @@ description: 纸拼贴 B-roll 的原子工具——环境自检与 Gate 3 批量 | 子命令 | 入 | 出 | 退出码 | |--------|----|----|--------| | `collage-broll check-setup` | 无(读环境变量与 PATH) | stdout 逐项 PASS/FAIL | 0 全通 / 1 有缺项 | -| `collage-broll gate3` | `--batch /gen-jobs.json`(每条含 prompt / first_frame / last_frame / output / ratio / resolution / duration),可选 `--dry-run` | 逐条调公共 `aigc-video-gen` i2v 落 MP4 + decisions.log | 0 全通 / 1 参数错、jobs 文件不存在或格式错、`aigc-video-gen` 不在 PATH / 2 部分 job 失败(stderr 报失败清单,已跑通的保留) | +| `collage-broll render` | `--batch /render/gen-jobs.json`(每条含 prompt / first_frame / last_frame / output / ratio / resolution / duration),可选 `--dry-run` | 逐条调公共 `aigc-video-gen` i2v 落 MP4 + decisions.log | 0 全通 / 1 参数错、jobs 文件不存在或格式错、`aigc-video-gen` 不在 PATH / 2 部分 job 失败(stderr 报失败清单,已跑通的保留) | **调用方式**: ```bash collage-broll check-setup -collage-broll gate3 --batch output_videos//gen-jobs.json --dry-run -collage-broll gate3 --batch output_videos//gen-jobs.json +collage-broll render --batch output_videos//render/gen-jobs.json --dry-run +collage-broll render --batch output_videos//render/gen-jobs.json ``` **注意事项**: -- `gate3` 串行调度:视频生成是异步轮询任务,并行会撞平台并发限。 +- `render` 串行调度:视频生成是异步轮询任务,并行会撞平台并发限。 - `gen-jobs.json` 的 `output` 必须是相对 workspace 根、且落在 `output_videos/` 下的路径(`aigc-video-gen` 的 ensure_safe_output 要求);调用时 workdir 必须是 Content Producer workspace 根。 -- 依赖:`ffmpeg` / `ffprobe` / Python ≥ 3.10;`AWK_API_KEY`(Gate 2 静帧);`MODELSTUDIO_API_KEY` 或 `DASHSCOPE_API_KEY`(百炼)或 `AWK_GEN_KEY`(火山)。缺项由 `check-setup` 报出,补齐属 IT engineer 职责。 +- 依赖:`ffmpeg` / `ffprobe` / Python ≥ 3.10;`AWK_API_KEY`(Phase 2 静帧);`MODELSTUDIO_API_KEY` 或 `DASHSCOPE_API_KEY`(百炼)或 `AWK_GEN_KEY`(火山)。缺项由 `check-setup` 报出,补齐属 IT engineer 职责。 - 模型候选链 fallback 与 decisions.log 由 `aigc-video-gen` 自带,本工具不重复实现。 diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh b/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh index 60d36095..5df04f9b 100755 --- a/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh @@ -2,8 +2,8 @@ # collage-broll.sh — collage-broll 工具 wrapper(薄转发,子命令范式) # 让 agent 用 `collage-broll <子命令> [参数...]` 走 PATH,零路径拼接。 # 子命令: -# check-setup 环境自检(ffmpeg/ffprobe/AWK_API_KEY/视频平台 key/python 版本) -# gate3 --batch [--dry-run] Gate 3 批量调度 i2v 生成 +# check-setup 环境自检(ffmpeg/ffprobe/AWK_API_KEY/视频平台 key/python 版本) +# render --batch [--dry-run] Stage 10 批量调度 i2v 生成 # 纸拼贴 B-roll 的完整制作流程见 expert-video 包内 Collage B-roll Workflow。 set -euo pipefail SELF="${BASH_SOURCE[0]}" @@ -11,31 +11,31 @@ SELF="${BASH_SOURCE[0]}" while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -SUBCMD="${1:?用法: collage-broll [参数...]}" +SUBCMD="${1:?用法: collage-broll [参数...]}" shift case "$SUBCMD" in check-setup) exec bash "$SCRIPT_DIR/scripts/check_setup.sh" "$@" ;; - gate3) - exec python3 "$SCRIPT_DIR/scripts/run_gate3.py" "$@" + render) + exec python3 "$SCRIPT_DIR/scripts/run_render.py" "$@" ;; -h|--help|help) cat <<'HELP' collage-broll — 纸拼贴 B-roll 原子工具(wrapper) 用法: - collage-broll check-setup 环境自检(依赖与 key) - collage-broll gate3 --batch Gate 3 批量 i2v 生成(串行调 aigc-video-gen) - collage-broll gate3 --batch <...> --dry-run 只打印调度计划不真调 - collage-broll help 本帮助 + collage-broll check-setup 环境自检(依赖与 key) + collage-broll render --batch Stage 10 批量 i2v 生成(串行调 aigc-video-gen) + collage-broll render --batch <...> --dry-run 只打印调度计划不真调 + collage-broll help 本帮助 退出码: check-setup 0 全通 / 1 有缺项 - gate3 0 全部 job 跑通 / 1 参数错或 wrapper 缺失 / 2 部分 job 失败(已跑通的保留) + render 0 全部 job 跑通 / 1 参数错或 wrapper 缺失 / 2 部分 job 失败(已跑通的保留) -制作流程(隐喻 → 静帧 → 视频三道闸门)见 expert-video 包内 Collage B-roll Workflow; -本 wrapper 只代理脚本,不含流程语义。 +制作流程(GATE A 隐喻清单 → GATE B 静帧 → Stage 10 i2v 组装)见 expert-video 包内 +Collage B-roll Workflow;本 wrapper 只代理脚本,不含流程语义。 HELP ;; *) diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh index aed734ee..701bf303 100644 --- a/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh @@ -2,7 +2,7 @@ # collage-broll environment self-check. # Exit 0 = all good; exit 1 = at least one item missing (details on stdout). # -# 探依赖:ffmpeg / ffprobe / AWK_API_KEY(Gate 2 静帧)/ 视频平台 key(Gate 3 视频) +# 探依赖:ffmpeg / ffprobe / AWK_API_KEY(Phase 2 静帧)/ 视频平台 key(Stage 10 i2v 视频) # 不探 venv——仓根 requirements.txt 统一装,不留独立 venv(xiaobei 语境) set -u @@ -12,20 +12,20 @@ FAIL=0 ok() { printf 'PASS %s\n' "$1"; } bad() { printf 'FAIL %s\n' "$1"; FAIL=1; } -# 1. AWK_API_KEY(Gate 2 静帧生成要——siliconflow-img-gen / Seedream) +# 1. 百炼生图凭据(Phase 2 静帧生成要——awk-img-gen:WORKSPACE_ID+MODELSTUDIO_API_KEY 或 AWK_API_KEY) if [ -n "${AWK_API_KEY:-}" ]; then - ok "AWK_API_KEY 已设置(Gate 2 静帧可用)" + ok "AWK_API_KEY 已设置(Phase 2 静帧可用)" else - bad "AWK_API_KEY 未设置(Gate 2 静帧生成要——到 https://console.volcengine.com/ark 创建后 export 到 shell 配置)" + bad "AWK_API_KEY 未设置(Phase 2 静帧生成要——到 https://console.volcengine.com/ark 创建后 export 到 shell 配置)" fi -# 2. 视频平台 key(Gate 3 视频生成要——aigc-video-gen / 百炼或火山) +# 2. 视频平台 key(Stage 10 i2v 视频生成要——aigc-video-gen / 百炼或火山) if [ -n "${MODELSTUDIO_API_KEY:-}" ] || [ -n "${DASHSCOPE_API_KEY:-}" ]; then - ok "MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY 已设置(Gate 3 走百炼 happyhorse-1.1-i2v)" + ok "MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY 已设置(Stage 10 i2v 走百炼 happyhorse-1.1-i2v)" elif [ -n "${AWK_GEN_KEY:-}" ]; then - ok "AWK_GEN_KEY 已设置(Gate 3 走火山 Seedance,百炼未配)" + ok "AWK_GEN_KEY 已设置(Stage 10 i2v 走火山 Seedance,百炼未配)" else - bad "视频平台 key 都未设置(Gate 3 要 MODELSTUDIO_API_KEY 百炼 或 AWK_GEN_KEY 火山)" + bad "视频平台 key 都未设置(Stage 10 i2v 要 MODELSTUDIO_API_KEY 百炼 或 AWK_GEN_KEY 火山)" fi # 3. ffmpeg / ffprobe diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_gate3.py b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_render.py similarity index 89% rename from crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_gate3.py rename to crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_render.py index 746529e6..a8fd2115 100644 --- a/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_gate3.py +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_render.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Gate 3 批量调度——读 gen-jobs.json 逐条调公共 aigc-video-gen wrapper 走 i2v 模式(首尾帧插值)。 +"""Stage 10 render 批量调度——读 gen-jobs.json 逐条调公共 aigc-video-gen wrapper 走 i2v 模式(首尾帧插值)。 每个 job 字段: prompt aigc-video-gen --prompt(中文声画同出描述) @@ -14,8 +14,8 @@ 串行调(视频生成是异步轮询任务,并行调会撞平台并发限)。 Usage: - python3 /scripts/run_gate3.py --batch /gen-jobs.json - python3 /scripts/run_gate3.py --batch /gen-jobs.json --dry-run + python3 /scripts/run_render.py --batch /render/gen-jobs.json + python3 /scripts/run_render.py --batch /render/gen-jobs.json --dry-run Exit codes: 0 全部 job 跑通 @@ -74,7 +74,7 @@ def run_one(job: dict, job_id: int, dry_run: bool) -> tuple[bool, str]: def main() -> None: parser = argparse.ArgumentParser( - description="Gate 3 批量调度——读 gen-jobs.json 逐条调公共 aigc-video-gen wrapper 走 i2v(首尾帧插值)." + description="Stage 10 render 批量调度——读 gen-jobs.json 逐条调公共 aigc-video-gen wrapper 走 i2v(首尾帧插值)." ) parser.add_argument("--batch", required=True, help="gen-jobs.json 路径") parser.add_argument("--dry-run", action="store_true", help="只打印不真调") diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md b/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md index 39a5acc5..f29821f5 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md +++ b/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md @@ -1,6 +1,6 @@ --- name: video-producer -description: 视频制作原子能力集——意图路由、故事/剧本/分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面。子命令范式,产物文件存在性即 checkpoint。 +description: 视频制作原子能力集——剧本/分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面。子命令范式,产物文件存在性即 checkpoint。 --- # video-producer — 工具说明 @@ -19,22 +19,20 @@ description: 视频制作原子能力集——意图路由、故事/剧本/分 | 子命令 | 入 | 出 | 用途 | |--------|----|----|------| -| `intent-router` | brief.md(主题/关键词/类型) | `script/intent.json`(档位+主题) | 意图路由三档:故事讲述型 narrative / 纯画面动效型 motion / 蒙太奇剪接型 montage | | `reference-concepts` | 甲方给的参考拆解报告(可选) | `reference/concepts.md` | 据报告出 2–3 个差异化概念;不做下载/转写/抽帧 | -| `story-develop` | intent.json | `script/story.md` | idea → 故事(受众/类型复述、100–200 词梗概、人物、分场) | -| `script-write` | story.md | `script/script.md`(含 enhancement_cues 六型 + delivery_cues) | 故事 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) | +| `script-write` | brief.md(创意 + 规格) | `script/script.md`(含 enhancement_cues 六型 + delivery_cues) | Brief 创意 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) | | `script-self-eval` | script.md | `script/self-eval.json` | 脚本自评 N 维打分,任一维 <3 必返工 | | `storyboard-build` | script.md | `storyboard/storyboard.json` | 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) | | `shot-decompose` | storyboard.json | `storyboard/shot_decompose.json` | 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) | -| `character-register` | storyboard.json + brief.md | `characters/registry.json` + 三视图 png | 角色 static/dynamic features 拆分 + front/side/back(调 `siliconflow-img-gen`) | +| `character-register` | shot_decompose.json + script.md | `characters/registry.json` + 三视图 png | 角色 static/dynamic features 拆分 + front/side/back(调 `awk-img-gen`) | | `slot-plan` | storyboard.json + shot_decompose.json | `slots/slot-plan.json` | 素材 slot 规划(template + hero slot + tone→slot 数) | | `asset-resolve` | slot-plan.json | `slots/asset-resolve.json`(含 rejected_picks)+ 素材落 `raw_materials/` | 按 slot 拉素材(Fast path:多源并发搜 + 缩略图人核;调 pexels-footage / pixabay-footage / aigc-video-gen) | | `slideshow-risk` | storyboard.json + slot-plan.json + asset-resolve.json | `slots/slideshow-risk.json` | 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail) | | `delivery-promise-lock` | storyboard.json + brief.md | `slots/delivery-promise.json` | 交付承诺八类锁定 + motion_ratio 预估 | -| `render-shot` | shot_decompose.json + characters/ + slot-picks | `render/shot-NN/` 下产物 | 按 slot 渲染(AIGC 走 `aigc-video-gen` i2v 首尾帧插值;静图走 `siliconflow-img-gen`) | +| `render-shot` | shot_decompose.json + characters/ + slot-picks | `render/shot-NN/` 下产物 | 按 slot 渲染(AIGC 走 `aigc-video-gen` i2v 首尾帧插值;静图走 `awk-img-gen`) | | `motion-graphics` | ` --spec mg.json [--out clip.mp4] [--duration] [--force]` | 单个动态图形 clip.mp4(默认 `render/mg//`) | 程序化逐帧动画(Stage 10 第二条渲染路径,与 render-shot 并列):声明式 spec,内置四模板(dimension_grid 逐维点亮 / scroll_cards 滚动卡组 / crew_panel 角色卡入位 / rec_highlight 录屏圈选)+ 基础元素(text/card/photo_circle/glow/band/highlight_zone/progress_bar)+ custom 插件逃生舱;帧级 checkpoint、时长/帧率断言;只出单段,不拼接不混音不做字幕 | | `mix-audio` | script.md(delivery_cues) | `audio/` 目录 + `subtitles.srt` 模板 | 配音配乐四场景分流:A 人物对话声画同出 / B 旁白一次性 TTS 带字级时间戳 + 对齐 / C BGM 成片后统一生成 / D 甲方口播录音 → ASR 时间戳 → 按时间戳补素材 | -| `narration-align` | audio/narration.mp3 + audio/narration.subtitle.json | `audio/narration-segments.json` | 旁白字级时间戳对齐(**整段模式**:一条连续 narration.mp3;优先复用 `awk-tts --enable-subtitle` 的原生时间戳,缺失时回退火山 ASR 极速版,凭据 `VOLC_ASR_*`) | +| `narration-align` | audio/narration.mp3 + audio/narration.subtitle.json | `audio/narration-segments.json` | 旁白字级时间戳对齐(**整段模式**:一条连续 narration.mp3;优先复用 `awk-tts --enable-subtitle` 的原生时间戳,缺失时回退公共 ASR 路由:火山 → 百炼) | | `narration-layout` | ` --plan narration_plan.json [--srt ...] [--mix ...] [--force]` | `audio/abs_starts.json` + SRT + 可选混音 | 逐句旁白排布(**逐句模式**:每句独立 mp3,与 narration-align 互补):实测镜头时长累积起点 → 每句对齐镜头起点 + 防重叠守卫 → 逐句/末句越界断言(违反非零退出打印明细)→ SRT(样式参数化,force_style 落 abs_starts.json 供 burn-srt 引用)→ 可选一步混音(内部复用 audio-mix:N 路旁白 + BGM fade) | | `clip-trim` | `--input/--output/--start/--end/--speed/--sync-audio/--pre-buffer/--duration/--normalize/--grade/--windows/--zoompan/--low-load` | 切好的片段 | 精确切素材段(入点/出点/倍速/前置缓冲,视频、音频、图片分别处理;`--pre-buffer 0.5` 防切 MP3 吞首字);`--normalize 1920x1080@25` 切片即归一(scale+pad+sar+fps);`--grade warm` 预设调色;`--windows "12.0:3.2,44.5:1.4"` 一镜多窗切后 concat;`--zoompan 1.08` 定帧缓推(视频源在 --start 取帧);`--low-load` 低载编码(nice19/veryfast/crf18/threads2) | | `audio-mix` | `--track(可重复)/--delay/--volume/--fadein/--fadeout/--output/--duration` | 混合音频 | 多轨混音(每轨独立延时、音量与淡入淡出;`--duration` 为硬上限:短轨补虚、超出截断) | @@ -44,7 +42,7 @@ description: 视频制作原子能力集——意图路由、故事/剧本/分 | `add-silent-audio` | `--input/--output/--duration/--sample-rate/--channels` | 含静音音轨的视频 | 给无音频片段补静音轨(concat 前置;assemble 内部也自动调) | | `make-outro` | ` --image <形象图> --slogan <文本> [--color color.json] [--duration 5] [--width 1080] [--fps 30]` | 标准比例片尾段 | 形象图 + 黑边 + 烧字幕 + 静音轨 | | `motion-audit` | video.mp4 + delivery-promise.json | `review/motion-audit.json` | motion_led 抽查(兑付交付承诺) | -| `make-cover` | brief.md(封面主文案)+ storyboard 关键帧 | `cover.jpg` | 封面生成(调 `siliconflow-img-gen`,必含封面主文案) | +| `make-cover` | brief.md(封面主文案)+ storyboard 关键帧 | `cover.jpg` | 封面生成(调 `awk-img-gen`,必含封面主文案) | ## 注意事项 diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_brief.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_brief.py new file mode 100644 index 00000000..2337f0c8 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_brief.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""_brief.py — Brief 字段解析共享助手(video-producer 流程链子命令用)。 + +从项目 brief.md 解析 workflow 字段与口播交付形态,供 script-write / +script-self-eval 做 workflow 感知,供各阶段脚本做 collage-broll 阶段裁剪守卫。 + +Brief 字段格式见 main 侧各平台 content-production 的 Brief 模板 +("- workflow:<值>",中文冒号;口播文案行给 voiceover.md 绝对路径 / 录音路径 / 不适用)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +TYPE_WORKFLOWS = {"reversal-ad", "narration-video", "collage-broll"} + +_AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".amr"} + + +def read_brief(project: Path) -> str: + brief = Path(project) / "brief.md" + if not brief.is_file(): + return "" + return brief.read_text(encoding="utf-8") + + +def parse_workflow(brief_text: str) -> str | None: + """解析 Brief 的 workflow 字段。 + + 返回 'reversal-ad' / 'narration-video' / 'collage-broll'; + 未指定、模板未填(枚举行原样 / 省略标注)或无 Brief → None。 + """ + for line in brief_text.splitlines(): + m = re.match(r"^-\s*workflow\s*[::]\s*(.+)$", line.strip()) + if not m: + continue + val = m.group(1).strip().strip("`\"'") + # 模板未填(多值枚举行原样)或明确省略 → 未指定 + if "省略" in val or "未指定" in val or val.startswith("reversal-ad /"): + return None + if val in TYPE_WORKFLOWS: + return val + # 单值带尾注(如 "reversal-ad(…)") + head = val.split("(")[0].split("(")[0].strip() + return head if head in TYPE_WORKFLOWS else None + return None + + +def detect_voiceover(project: Path, brief_text: str) -> tuple[str | None, Path | None]: + """解析口播交付形态(口播 = 真人出镜 / 数字人 / 真人录音)。 + + 返回 ('voiceover', Path) / ('recording', Path) / (None, None)。 + 都没有 → 旁白形态(TTS 配音解说,文稿由 CP 写)。 + """ + project = Path(project) + cand = project / "voiceover.md" + if cand.is_file(): + return "voiceover", cand + for line in brief_text.splitlines(): + if "口播" not in line: + continue + for tok in re.findall(r"[\w./\-]+", line): + p = Path(tok) + if not p.suffix: + continue + if p.suffix == ".md" and "voiceover" in p.stem.lower() and p.is_file(): + return "voiceover", p + if p.suffix.lower() in _AUDIO_EXTS and p.is_file(): + return "recording", p + return None, None + + +def collage_guard(project: Path, stage: str) -> bool: + """collage-broll 阶段裁剪守卫。 + + Brief 指定 collage-broll 且本阶段被其裁剪时,打印跳过指引并返回 True + (caller 应据此 return,退出码 0)。其余情况返回 False,照常执行。 + """ + if parse_workflow(read_brief(project)) != "collage-broll": + return False + print(f"[skip] collage-broll 类型裁剪本阶段({stage})——阶段裁剪表见 workflows/collage-broll.md:") + print(" Stage 3–9 由静帧生成(Phase 2)替代;视频生成走 `collage-broll render`(Phase 3);") + print(" 默认无声、逐条独立成片不经拼接。按 workflow 文档执行,不要调用本命令。") + return True diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py index 339d40d7..ef35dd9b 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py @@ -32,6 +32,8 @@ import sys from pathlib import Path +import _brief + VALID_TRANSITIONS = {"hard", "fade", "dissolve", "xfade"} XFADE_TYPES = {"fade": "fade", "dissolve": "dissolve", "xfade": "fade"} @@ -412,6 +414,8 @@ def main() -> None: args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 12 assemble"): + return source_dir = project / args.source_dir if args.source_dir else project / "render" preset, crf = encode_opts(args.low_memory) audio_sr, audio_ch = parse_audio_format(args.audio_format) diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py index b5882bba..cdedec71 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Stage 8 — asset-resolve:按 slot 拉素材(Fast path)。 +"""Stage 7 — asset-resolve:按 slot 拉素材(Fast path)。 Usage: python3 scripts/asset-resolve.py [--source pexels|pixabay|both] [--no-confirm] -入:project_dir/slots/slot-plan.json(Stage 7) +入:project_dir/slots/slot-plan.json(Stage 6) 出:project_dir/slots/asset-resolve.json(每 slot 选定素材 + rejected_picks 落盘) + 素材落 project_dir/raw_materials/ @@ -29,6 +29,8 @@ import sys from pathlib import Path +import _brief + def die(msg: str) -> None: print(f"[error] {msg}", file=sys.stderr) @@ -36,13 +38,15 @@ def die(msg: str) -> None: def main() -> None: - parser = argparse.ArgumentParser(description="Stage 8 asset-resolve") + parser = argparse.ArgumentParser(description="Stage 7 asset-resolve") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--source", default="both", choices=["pexels", "pixabay", "both"]) parser.add_argument("--no-confirm", action="store_true", help="agent 已人核完毕,不再呈交") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 7 asset-resolve"): + return plan_path = project / "slots" / "slot-plan.json" if not plan_path.is_file(): die(f"前置缺失: slot-plan.json 不存在") @@ -59,7 +63,7 @@ def main() -> None: return stub = { - "stage": 8, + "stage": 7, "source": args.source, "raw_materials_dir": str(raw_dir), "instruction": ( @@ -83,7 +87,7 @@ def main() -> None: } resolve_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] asset-resolve.json 模板已落:{resolve_path}") - print(f"[next] agent 跑 Fast path 填 picks → 跑 slideshow-risk(Stage 9a)") + print(f"[next] agent 跑 Fast path 填 picks → 跑 slideshow-risk(Stage 8)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py index 943cf7c5..3e3da99e 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Stage 6 — character-register:角色三视图 + static/dynamic features 拱分。 +"""Stage 5 — character-register:角色三视图 + static/dynamic features 拆分。 Usage: python3 scripts/character-register.py -入:project_dir/storyboard/shot_decompose.json(Stage 5)+ story.md(Stage 2 人物段) +入:project_dir/storyboard/shot_decompose.json(Stage 4)+ script.md(Stage 1 出场人物) 出:project_dir/characters/registry.json(每个角色 static/dynamic features) - + project_dir/characters//front.png + side.png + back.png(调 siliconflow-img-gen) + + project_dir/characters//front.png + side.png + back.png(调 awk-img-gen) 三视图:front / side / back 三张同角色不同视角的静照,保证后续镜头里角色机位一致性。 static features:跨镜不变的(发色/衣着/体型/年龄感) @@ -21,6 +21,8 @@ import sys from pathlib import Path +import _brief + def die(msg: str) -> None: print(f"[error] {msg}", file=sys.stderr) @@ -28,15 +30,17 @@ def die(msg: str) -> None: def main() -> None: - parser = argparse.ArgumentParser(description="Stage 6 character-register") + parser = argparse.ArgumentParser(description="Stage 5 character-register") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 5 character-register"): + return decompose_path = project / "storyboard" / "shot_decompose.json" - story_path = project / "script" / "story.md" - if not decompose_path.is_file() or not story_path.is_file(): - die("前置缺失: shot_decompose.json 或 story.md 不存在") + script_path = project / "script" / "script.md" + if not decompose_path.is_file() or not script_path.is_file(): + die("前置缺失: shot_decompose.json 或 script.md 不存在") registry_path = project / "characters" / "registry.json" registry_path.parent.mkdir(parents=True, exist_ok=True) @@ -49,11 +53,11 @@ def main() -> None: return stub = { - "stage": 6, + "stage": 5, "characters": [], "instruction": ( - "agent 据 story.md 人物段 + shot_decompose.json 列出所有出场角色,每个角色填 schema 并调 " - "siliconflow-img-gen 生成 front/side/back 三视图落 characters//。" + "agent 据 script.md 出场人物 + shot_decompose.json 列出所有出场角色,每个角色填 schema 并调 " + "awk-img-gen 生成 front/side/back 三视图落 characters//。" "static features 跨镜不变(发色/衣着/体型/年龄感),dynamic features 随镜变(表情/姿势/光影)。" "best_image_selector 走 agent 看 contact sheet 人核,不引 CLIP。" ), @@ -77,7 +81,7 @@ def main() -> None: } registry_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] registry.json 模板已落:{registry_path}") - print(f"[next] agent 填角色 schema + 调 siliconflow-img-gen 生成三视图 → GATE A 文本闸门") + print(f"[next] agent 填角色 schema + 调 awk-img-gen 生成三视图 → GATE A 文本闸门") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py index c9e3b049..245cd7c8 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage 9b — delivery-promise-lock:交付承诺八类锁定 + motion_ratio 预估。 +"""Stage 9 — delivery-promise-lock:交付承诺八类锁定 + motion_ratio 预估。 Usage: python3 scripts/delivery-promise-lock.py @@ -25,6 +25,8 @@ import sys from pathlib import Path +import _brief + def die(msg: str) -> None: print(f"[error] {msg}", file=sys.stderr) @@ -32,11 +34,13 @@ def die(msg: str) -> None: def main() -> None: - parser = argparse.ArgumentParser(description="Stage 9b delivery-promise-lock") + parser = argparse.ArgumentParser(description="Stage 9 delivery-promise-lock") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 9 delivery-promise-lock"): + return board_path = project / "storyboard" / "storyboard.json" if not board_path.is_file(): die(f"前置缺失: storyboard.json 不存在") @@ -52,7 +56,7 @@ def main() -> None: return stub = { - "stage": "9b", + "stage": "9", "promises": { "has_dialogue": None, "has_narration": None, diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py index 80f0fd43..4e373685 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py @@ -2,13 +2,13 @@ """BGM ducking — narration/dialog drives BGM auto-ducking via sidechain. 把 BGM 轨在旁白/对话出现时自动压低,旁白停了再放开——专业混音的标配。 -只在声画同出模式(gen.py 出的片旁白+BGM 同轨)且用户要专业混音时用。 +只在声画同出模式(aigc-video-gen 出的片旁白+BGM 同轨)且用户要专业混音时用。 ⚠️ 可选步骤,不是必跑。通用制作流程默认不做混音处理—— assemble.py / normalize.py 都只碰整体响度,不动轨间电平。 **仅当用户明确说"要混音"/"做 ducking"/"BGM 压旁白"/"professional mix"时才跑**。 -前置:要有可分离的 BGM 轨和旁白轨。AI 声画同出模式 gen.py 出的片是 +前置:要有可分离的 BGM 轨和旁白轨。AI 声画同出模式 aigc-video-gen 出的片是 **混轨单声道**——duck.py 没法从混轨里分离 BGM 和旁白。所以本脚本实际 只在以下场景能用: 1. assemble.py 走 Stock Footage + TTS 模式:素材视频(含 BGM/环境音)+ diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py deleted file mode 100644 index c8f673d7..00000000 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -"""Stage 1 — intent-router:把 Brief 意图路由成三档脚本模板。 - -三档(内部 key 保留英文,user-facing 表述用中文名): -- narrative(故事讲述型)——重情节、有人物弧光、含旁白 → 默认 3–5 镜/场 -- motion(纯画面动效型)——重节奏感/视觉冲击/少对白 → 默认 5–8 镜快切 -- montage(蒙太奇剪接型)——重氛围/抽象/纯视觉 → 默认 4–7 镜无叙事 - -Usage: - video-producer intent-router [--user-text "..."] [--report-file path] - -入:project_dir(CP 自建工作区 output_videos//),可选甲方原文或参考拆解报告路径 -出:project_dir/script/intent.json(档位 + 主题 + 受众 + 时长目标 + 备选 + 决策理由) - -产物文件存在性即 checkpoint:intent.json 已存在则打印现状退出,不重生成(用户手改后续跑)。 -""" - -import argparse -import json -import sys -from pathlib import Path - -VALID_GENRES = {"narrative", "motion", "montage"} -DURATION_DEFAULTS = {"narrative": 30, "motion": 25, "montage": 20} -SHOT_DEFAULTS = {"narrative": (3, 5), "motion": (5, 8), "montage": (4, 7)} - - -def die(msg: str) -> None: - print(f"[error] {msg}", file=sys.stderr) - sys.exit(1) - - -def detect_genre(text: str) -> tuple[str, str]: - """从用户文本特征粗判档位。返 (genre, reason)。""" - lower = text.lower() - if any(k in lower for k in ["故事", "情节", "人物", "弧光", "narrative", "story", "plot", "character"]): - return "narrative", "用户提及故事/情节/人物" - if any(k in lower for k in ["节奏", "冲击", "快切", "motion", "beat", "impact", "rhythm"]): - return "motion", "用户提及节奏/冲击/快切" - if any(k in lower for k in ["氛围", "抽象", "纯视觉", "montage", "vibe", "abstract", "atmosphere"]): - return "montage", "用户提及氛围/抽象/纯视觉" - return "narrative", "无明确信号,退默认 narrative(最通用)" - - -def main() -> None: - parser = argparse.ArgumentParser(description="Stage 1 intent-router") - parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") - parser.add_argument("--user-text", default=None, help="甲方原文(Brief 主题句或用户描述)") - parser.add_argument("--report-file", default=None, help="甲方给的参考拆解报告路径(可选)") - parser.add_argument("--genre", default=None, choices=sorted(VALID_GENRES), help="强制档位,跳过自动判定") - parser.add_argument("--duration", type=int, default=None, help="时长目标(秒),不传走档位默认") - parser.add_argument("--audience", default=None, help="受众描述") - args = parser.parse_args() - - project = Path(args.project_dir).resolve() - if not project.is_dir(): - die(f"project_dir 不存在或非目录: {args.project_dir}") - intent_path = project / "script" / "intent.json" - intent_path.parent.mkdir(parents=True, exist_ok=True) - - # checkpoint:产物已存在则不重生成 - if intent_path.is_file(): - existing = json.loads(intent_path.read_text(encoding="utf-8")) - print(f"[checkpoint] intent.json 已存在,沿用:") - print(json.dumps(existing, ensure_ascii=False, indent=2)) - return - - text = args.user_text or "" - if not text and args.report_file: - report = Path(args.report_file) - if report.is_file(): - text = report.read_text(encoding="utf-8")[:2000] - - if not text: - die("需 --user-text 或 --report-file 之一作为意图来源") - - genre, reason = (args.genre, "用户强制档位") if args.genre else detect_genre(text) - duration = args.duration or DURATION_DEFAULTS[genre] - shot_min, shot_max = SHOT_DEFAULTS[genre] - - intent = { - "stage": 0, - "genre": genre, - "topic": text[:200], - "audience": args.audience or "未指定", - "duration_target": duration, - "shot_count": {"min": shot_min, "max": shot_max}, - "decisions": [ - { - "choice": f"genre={genre}", - "alternatives": sorted(VALID_GENRES - {genre}), - "confidence": 0.7 if not args.genre else 1.0, - "reason": reason, - } - ], - } - intent_path.write_text(json.dumps(intent, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"[done] 路由完成:genre={genre} duration={duration}s shots={shot_min}-{shot_max}") - print(f"[done] 产物:{intent_path}") - print(f"[next] 跑 story-develop(Stage 2)") - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py index 60b679c0..fdbb11c4 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage 14a — make-cover:封面(siliconflow-img-gen,必含封面主文案)。 +"""Stage 14a — make-cover:封面(awk-img-gen,必含封面主文案)。 Usage: python3 scripts/make-cover.py --title "..." @@ -7,7 +7,7 @@ 入:project_dir/brief.md(封面主文案)+ storyboard 关键帧 出:project_dir/cover.jpg(含封面主文案的封面图) -封面硬约束:必含封面主文案。主文案由甲方在 Brief 中给出(有平台标题时用标题,视频号用短标题);Brief 未给时回退核心传达。siliconflow-img-gen 不一定能把中文封面主文案烤进图, +封面硬约束:必含封面主文案。主文案由甲方在 Brief 中给出(有平台标题时用标题,视频号用短标题);Brief 未给时回退核心传达。awk-img-gen 不一定能把中文封面主文案烤进图, agent 生成后用 image 工具看,确认封面主文案可见——不可见就用 ImageMagick/Pillow 烧字上去。 """ @@ -52,14 +52,14 @@ def main() -> None: stub = { "cover_copy": title, "instruction": ( - "agent 调公共 siliconflow-img-gen 生成封面图,prompt 必含封面主文案指令。" + "agent 调公共 awk-img-gen 生成封面图,prompt 必含封面主文案指令。" "生成后用 image 工具看,确认封面主文案可见——不可见就用 ImageMagick/Pillow 烧字上去。" "落 cover.jpg 到项目根。" ), "cover_path": str(cover), } print(f"[plan] cover.jpg 封面主文案:{title}") - print(f"[next] agent 调 siliconflow-img-gen 生成 → 确认封面主文案可见 → 落 {cover}") + print(f"[next] agent 调 awk-img-gen 生成 → 确认封面主文案可见 → 落 {cover}") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py index df734fb7..01eeec67 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -"""Stage 11 — mix-audio:配音配乐三场景分流。 +"""Stage 11 — mix-audio:配音配乐四场景分流。 Usage: python3 scripts/mix-audio.py 入:project_dir/script/script.md(含 delivery_cues / 对白 / 旁白标记) 出:project_dir/audio/ 目录 + subtitles.srt 模板 - (实际音频产物由 agent 按下方三场景路径生成) + (实际音频产物由 agent 按下方四场景路径生成) -三场景分流(agent 按 script.md 实际内容判断走哪条): +四场景分流(agent 按 script.md 实际内容判断走哪条): A. 人物对话 → 声画同出 aigc-video-gen i2v 渲染时人物对白自带语音,不单独做 TTS。 @@ -17,7 +17,7 @@ B. 旁白 → 一次性 TTS + ASR 对齐 1. agent 把整片旁白词写好,一次性 TTS 生成 audio/narration.mp3 (保证语音一致性,不要分段生成) - 2. 跑 narration-align(Stage 11b): + 2. 跑 narration-align(Stage 11): python3 scripts/narration-align.py → 调火山 ASR 极速版对 narration.mp3 转写,输出 audio/narration-segments.json(utterance 级真实时间戳,秒) @@ -30,13 +30,20 @@ - pexels-footage / pixabay-footage 搜 background music - 静音占位(无 BGM 需求时) -混合场景:A+B+C、B+C、A+C 均可能,agent 按 script.md 判断。 + D. 甲方口播录音 → ASR 时间戳 → 按时间戳补素材 + 1. 甲方口播录音落 audio/voiceover.(Brief 给绝对路径;文案落稿锁定,不重写) + 2. 跑 narration-align 拿 utterance 级真实时间戳 + 3. agent 拿时间戳按句排素材,assemble 时混入;不重配旁白 + +混合场景:A/B/C/D 任意组合均可能,agent 按 script.md 与 Brief 判断。 """ import argparse import sys from pathlib import Path +import _brief + def main() -> None: parser = argparse.ArgumentParser(description="Stage 11 mix-audio") @@ -44,6 +51,8 @@ def main() -> None: args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 11 mix-audio"): + return script_path = project / "script" / "script.md" if not script_path.is_file(): die(f"前置缺失: script.md 不存在") @@ -61,7 +70,7 @@ def main() -> None: print(f"[done] audio/ 目录已建 + subtitles.srt 模板已落") print() - print("=== 配音配乐三场景分流(agent 按 script.md 判断走哪条)===") + print("=== 配音配乐四场景分流(agent 按 script.md 判断走哪条)===") print() print("A. 人物对话 → 声画同出") print(" aigc-video-gen i2v 渲染时人物对白自带语音,不单独做 TTS。") @@ -73,7 +82,7 @@ def main() -> None: print(" 调 awk-tts 时加 --enable-subtitle,火山单向流式 HTTP 原生返回") print(" 字级时间戳(sentence.words 带 startTime/endTime,秒),") print(" awk-tts 自动落盘 audio/narration.subtitle.json") - print(" 2. 跑 narration-align(Stage 11b):") + print(" 2. 跑 narration-align(Stage 11):") print(" python3 scripts/narration-align.py ") print(" → 优先复用 narration.subtitle.json(TTS 原生字级时间戳,零额外调用)") print(" → 缺失时回退火山 ASR 极速版转写 narration.mp3") @@ -87,10 +96,10 @@ def main() -> None: print(" - pexels-footage / pixabay-footage 搜 background music") print(" - 静音占位(无 BGM 需求时)") print() - print("混合场景:A+B+C、B+C、A+C 均可能,agent 按 script.md 判断。") + print("混合场景:A/B/C/D 任意组合均可能,agent 按 script.md 与 Brief 判断。") print() - print("D. 用户口播录音 → ASR 时间戳 → 按时间戳补素材") - print(" 1. agent 把用户给的口播录音落 audio/voiceover.") + print("D. 甲方口播录音 → ASR 时间戳 → 按时间戳补素材") + print(" 1. agent 把甲方给的口播录音落 audio/voiceover.(Brief 给绝对路径;文案落稿锁定,不重写)") print(" 2. 调火山 ASR 极速版转写口播录音,拿 utterance 级真实时间戳") print(" (凭据复用 viral-chaser 同池 VOLC_ASR_*,与 narration-align 回退路径同一接口)") print(" 3. agent 按时间戳把口播内容切成段,每段对应一个 shot 时长区间") diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py index bbaa6437..d034b962 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py @@ -1,25 +1,26 @@ #!/usr/bin/env python3 -"""Stage 11b — narration-align:旁白时间戳对齐。 +"""Stage 11 — narration-align:旁白时间戳对齐。 Usage: python3 scripts/narration-align.py -入:project_dir/audio/narration.mp3(Stage 11a 一次性 TTS 生成的整段旁白) +入:project_dir/audio/narration.mp3(Stage 11 场景 B 一次性 TTS 生成的整段旁白) + project_dir/audio/narration.subtitle.json(awk-tts --enable-subtitle 落盘的 TTS 原生字级时间戳,优先复用) 出:project_dir/audio/narration-segments.json { "text": "全文", "segments": [{"start": 0.0, "end": 2.3, "text": "第一句"}, ...], - "source": "tts-native" | "volc.bigasr.auc_turbo" + "source": "tts-native" | "asr" } 路径优先级: 1. TTS 原生字级时间戳(narration.subtitle.json 存在时直接复用,零额外调用) - 2. 火山 ASR 极速版回退(narration.subtitle.json 缺失时,base64 直传 narration.mp3 - 调 volc.bigasr.auc_turbo,拿 utterance 级真实时间戳) + 2. 公共 ASR 路由回退(narration.subtitle.json 缺失时,base64 直传 narration.mp3, + 拿 utterance/word 级真实时间戳) -凭据复用 viral-chaser 同池:VOLC_ASR_APP_ID + VOLC_ASR_ACCESS_KEY(旧控制台双头) -或 VOLC_ASR_APP_KEY(新控制台单头)。 +ASR 供应商路由(_shared/asr.py):火山极速版 → 百炼业务空间 → 百炼 agent plan。 +凭据:VOLC_ASR_*(火山)或 WORKSPACE_ID+MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY +(百炼业务空间)或 AWK_API_KEY(百炼 agent plan),任一组即可。 agent 拿到 segments 后,按各 shot 时长把旁白切片对应到镜。 """ @@ -30,10 +31,10 @@ import sys from pathlib import Path -# 注入 main 侧 _shared 到 sys.path,复用公共火山 ASR 脚本(与 talking-head-cut/scripts/cut_plan.py 同范式) -# 跨 crew 引用:content-producer → main/_shared,凭据同池 VOLC_ASR_*,无新增配置 -sys.path.insert(0, str(Path(__file__).resolve().parents[5] / "crews" / "main" / "skills" / "_shared")) -from volc_asr import volc_asr # noqa: E402 +# 注入 main 侧 _shared 到 sys.path,复用公共 ASR 路由(与 talking-head-cut/scripts/cut_plan.py 同范式) +# 跨 crew 引用:content-producer → main/_shared,供应商路由 火山→百炼业务空间→百炼 agent plan,凭据同池无新增配置 +sys.path.insert(0, str(Path(__file__).resolve().parents[6] / "main" / "skills" / "_shared")) +from asr import asr # noqa: E402 def die(msg: str) -> None: @@ -116,22 +117,22 @@ def try_tts_native(narration: Path, subtitle_arg: str | None, out_path: Path) -> def fallback_asr(narration: Path, out_path: Path) -> None: - """回退路径:调公共 volc_asr(火山录音文件极速版),拿 word 级真实时间戳。 + """回退路径:调公共 ASR 路由(火山 → 百炼),拿 word 级真实时间戳。 - 统一走 _shared/volc_asr.py,与 talking-head-cut / viral-chaser 共一份逻辑。 + 统一走 _shared/asr.py,与 talking-head-cut / viral-chaser 共一份逻辑。 原先本函数只拿 utterance 级,现统一拿 word 级(更精细对齐)。 """ - print(f"[info] {narration.stem}.subtitle.json 缺失,调火山 ASR 极速版转写 {narration.name} ...") - result = volc_asr(str(narration)) + print(f"[info] {narration.stem}.subtitle.json 缺失,调公共 ASR 路由转写 {narration.name} ...") + result = asr(str(narration)) if not result.get("ok"): - die(f"火山 ASR 失败: {result.get('error', '未知错误')}") + die(f"ASR 失败: {result.get('error', '未知错误')}") words = result.get("words") or [] if not words: # word 级为空时兜底用 utterance 级 utterances = result.get("utterances") or [] if not utterances: - die("火山 ASR 未返回 utterances/words,无法对齐") + die("ASR 未返回 utterances/words,无法对齐") segs = [ {"start": round(u["start"], 3), "end": round(u["end"], 3), "text": u["text"]} for u in utterances @@ -145,7 +146,7 @@ def fallback_asr(narration: Path, out_path: Path) -> None: out = { "text": result.get("text", "") or "", "segments": segs, - "source": "volc.bigasr.auc_turbo", + "source": "asr", } out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") @@ -153,7 +154,7 @@ def fallback_asr(narration: Path, out_path: Path) -> None: def main() -> None: load_env_file() - parser = argparse.ArgumentParser(description="Stage 11b narration-align") + parser = argparse.ArgumentParser(description="Stage 11 narration-align") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument( "--audio", @@ -198,9 +199,9 @@ def main() -> None: if try_tts_native(narration, args.subtitle, out_path): print(f"[done] {out_path.name} 已落(TTS 原生字级时间戳):{out_path}") else: - # 回退路径:火山 ASR 极速版 + # 回退路径:公共 ASR 路由(火山 → 百炼) fallback_asr(narration, out_path) - print(f"[done] {out_path.name} 已落(火山 ASR 回退):{out_path}") + print(f"[done] {out_path.name} 已落(ASR 回退):{out_path}") # 统一打印结果摘要 result = json.loads(out_path.read_text(encoding="utf-8")) diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/reference-concepts.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/reference-concepts.py index 0bd22d78..11443e66 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/reference-concepts.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/reference-concepts.py @@ -74,12 +74,12 @@ def main() -> None: ## 用户选定 -> 呈交甲方(Brief owner)选定一个概念,写入 brief.md。未选定前不进 Stage 2。 +> 呈交甲方(Brief owner)选定一个概念,写入 brief.md。未选定前不进基线生产(script-write)。 """ concepts_path.write_text(stub, encoding="utf-8") print(f"[done] 报告已存档:{archived}") print(f"[stub] concepts.md 模板已落:{concepts_path}") - print(f"[next] agent 据报告填概念 → 呈交甲方选定 → 回通用制作流程(story-develop)") + print(f"[next] agent 据报告填概念 → 呈交甲方选定 → 回通用制作流程(script-write)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py index 1a11c31b..72d0e8b1 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py @@ -6,7 +6,7 @@ 入:project_dir/storyboard/shot_decompose.json + characters/ + slots/asset-resolve.json 出:project_dir/render/shot-NN/ 下产物: - first-frame.png(首帧静照,调 siliconflow-img-gen 生成或素材裁切) + first-frame.png(首帧静照,调 awk-img-gen 生成或素材裁切) last-frame.png(尾帧静照) gen*.mp4(aigc-video-gen i2v 产物,首尾帧插值;实际产出名不固定,gen.mp4 / gen-run-v01.mp4 / gen-v2.mp4 等,assemble 自动识别取最新) settings.log @@ -22,6 +22,8 @@ import sys from pathlib import Path +import _brief + def die(msg: str) -> None: print(f"[error] {msg}", file=sys.stderr) @@ -36,6 +38,8 @@ def main() -> None: args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 10 render-shot"): + return decompose_path = project / "storyboard" / "shot_decompose.json" resolve_path = project / "slots" / "asset-resolve.json" if not decompose_path.is_file() or not resolve_path.is_file(): @@ -76,8 +80,8 @@ def main() -> None: "variation_type": variation, "reference_images": 1 if variation == "static" else 2, "calls": [ - "siliconflow-img-gen → first-frame.png", - "siliconflow-img-gen → last-frame.png" if variation != "static" else "(skip, same as first)", + "awk-img-gen → first-frame.png", + "awk-img-gen → last-frame.png" if variation != "static" else "(skip, same as first)", "aigc-video-gen i2v --first first-frame.png --last last-frame.png → gen-run-v01.mp4", ], } @@ -88,7 +92,7 @@ def main() -> None: if args.dry_run: print(f" [dry-run] 不真调") else: - print(f" [next] agent 调 siliconflow-img-gen + aigc-video-gen 落产物到 {shot_dir}/") + print(f" [next] agent 调 awk-img-gen + aigc-video-gen 落产物到 {shot_dir}/") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py index 44e7b86f..9d44f364 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 -"""Stage 3b — script-self-eval:剧本自评 N 维打分,任一维 <3 必返工。 +"""Stage 2 — script-self-eval:按 workflow 的自检标准给 script 打分。 Usage: python3 scripts/script-self-eval.py -入:project_dir/script/script.md(Stage 3) +入:project_dir/script/script.md(Stage 1)+ project_dir/brief.md(读 workflow) 出:project_dir/script/self-eval.json(N 维分 1–5 + 总评 + 是否必返工) -N 维(硬约束六条): -1. 可拍化:无不可见物描写 -2. 场次划分:同时间同地点一场 -3. 对白格式:引号统一 -4. enhancement_cues:六型齐 -5. delivery_cues:语气/语速/重音齐 -6. 镜头数预算:在 intent.json 的 min-max 区间 +自检维度按 Brief 的 workflow 变体(脚手架据此落对应模板): +- 未指定 workflow → 通用五维(可拍化 / 场次 / 对白格式 / cues 齐) +- reversal-ad → GATE A 四问 + 叙事闭环 + 合规 +- narration-video → 口播稿落稿锁定模式(只检查不改写)或旁白稿规范 +- collage-broll → 隐喻自检五条 agent 据此逐维打分填 self-eval.json。脚本不做 NLP 判分——是 agent 的自检脚手架。 +任一维 <3 必返工;落稿锁定模式只检查不改写,问题报甲方。 """ import argparse @@ -23,25 +22,84 @@ import sys from pathlib import Path -EVAL_DIMS = [ +import _brief + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + +GENERIC_DIMS = [ ("filmable", "可拍化", "无不可见物描写('想起了'/'觉得'改外化动作)"), ("scene_split", "场次划分", "同时间同地点一场"), ("dialog_format", "对白格式", "引号「」统一"), ("enhancement_cues", "enhancement_cues 六型齐", "动作/表情/环境/心理外化/节奏/视觉锚点"), ("delivery_cues", "delivery_cues 齐", "语气/语速/重音/情感控制"), - ("shot_count", "镜数在预算区间", "intent.json 的 min-max"), +] + +REVERSAL_DIMS = [ + ("setup_credible", "铺垫可信", "反转点之前观众会认真相信这是纯内容视频(零广告感)"), + ("twist_magnitude", "反转幅度", "反转瞬间能让观众脱口而出「万万没想到」(题材/身份/场景/情绪跳得够远)"), + ("twist_smoothing", "接入丝滑", "接入句因果一句话可复述(自问自答/因果链/意象复用,非硬切)"), + ("cta_story", "CTA 剧情化", "设二次反转时 CTA 动机由剧情给出(未设则记 5 并注明未设)"), + ("narrative_loop", "叙事单线闭环", "节拍 ≤6、句间因果可追、无断头线;意象桥成立(产品段第一镜复用正文核心意象)"), + ("compliance", "合规", "正片零 URL/域名/联系方式,无收益承诺,产品能力不越 Brief"), +] + +NARRATION_LOCK_DIMS = [ + ("verbatim", "逐字一致", "与甲方 voiceover.md 逐字一致(落稿锁定只检查不改写,问题报甲方)"), + ("duration_fit", "时长适配", "文案长度适配 Brief 时长带(语速 6–8 字/秒;超带报甲方定夺)"), + ("compliance", "合规", "无越 Brief 的品牌事实、承诺与红线内容"), +] + +NARRATION_TEXT_DIMS = [ + ("filmable", "可配画面", "每句可独立配画面(抽象概念落到具体视觉)"), + ("core_alignment", "核心传达一致", "旁白与 Brief 核心传达 / CTA 要求一致"), + ("duration_fit", "时长适配", "语速 6–8 字/秒下适配 Brief 时长带"), + ("delivery_cues", "delivery_cues 齐", "语气/语速/重音/情感控制"), +] + +RECORDING_DIMS = [ + ("timeline_complete", "时间轴完整", "句 → 时间段 → 画面对应齐全(时间戳 Stage 11 场景 D ASR 后回填)"), + ("duration_fit", "时长适配", "录音总长与 Brief 时长带匹配"), +] + +COLLAGE_DIMS = [ + ("single_metaphor", "单一隐喻", "一条文稿只表达一个清晰隐喻,不逐字进画面"), + ("object_count", "物件 3–6", "关键物件 3–6 个,不是满屏碎片"), + ("color_semantics", "色彩语义", "底色与点色按语义色场表选,有理由"), + ("batch_narrative", "批量叙事", "批量时前后叙事成立,或每条独立成立"), + ("translatability", "可转译", "隐喻一眼可懂、可拆 3–6 个可分离纸片组供静帧生成"), ] def main() -> None: - parser = argparse.ArgumentParser(description="Stage 3b script-self-eval") + parser = argparse.ArgumentParser(description="Stage 2 script-self-eval(按 workflow 自检标准)") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() script_path = project / "script" / "script.md" if not script_path.is_file(): - die(f"前置缺失: script.md 不存在,先跑 script-write(Stage 3)") + die(f"前置缺失: script.md 不存在,先跑 script-write(Stage 1)") + + brief_text = _brief.read_brief(project) + workflow = _brief.parse_workflow(brief_text) + vo_mode, _ = _brief.detect_voiceover(project, brief_text) + + # 维度按 workflow + 口播交付形态选择 + if workflow == "reversal-ad": + dims, mode_note = REVERSAL_DIMS, "reversal-ad:GATE A 四问质检标准" + elif workflow == "collage-broll": + dims, mode_note = COLLAGE_DIMS, "collage-broll:隐喻自检(GATE A 质检标准)" + elif workflow == "narration-video" and vo_mode in ("voiceover", "recording"): + dims, mode_note = (NARRATION_LOCK_DIMS if vo_mode == "voiceover" else RECORDING_DIMS), \ + f"narration-video · {'口播稿落稿锁定——只检查不改写,问题报甲方' if vo_mode == 'voiceover' else '真人录音定稿——只检查排布计划'}" + elif workflow == "narration-video": + dims, mode_note = NARRATION_TEXT_DIMS, "narration-video · TTS 旁白稿规范" + else: + dims, mode_note = GENERIC_DIMS, "通用五维(未指定 workflow)" eval_path = project / "script" / "self-eval.json" eval_path.parent.mkdir(parents=True, exist_ok=True) @@ -54,18 +112,28 @@ def main() -> None: return stub = { - "stage": "3b", + "stage": "2", + "workflow": workflow, + "mode": mode_note, "dims": [ {"key": k, "name": n, "criteria": c, "score": None, "note": ""} - for k, n, c in EVAL_DIMS + for k, n, c in dims ], "overall": None, "must_rework": None, - "instruction": "agent 据每维 criteria 打分 1–5,note 写扣分理由。任一维 <3 必须 rework(重跑 script-write 改对应段后再跑本评估)。", + "instruction": ( + f"agent 据每维 criteria 打分 1–5,note 写扣分理由。任一维 <3 必须 rework(重跑 script-write 改对应段后再跑本评估)。" + + ("落稿锁定模式:只检查不改写——发现问题报甲方,不自行改稿。" if workflow == "narration-video" and vo_mode == "voiceover" else "") + ), } eval_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"[done] self-eval.json 模板已落:{eval_path}") - print(f"[next] agent 逐维打分 → 任一维 <3 必返工 → 全维 ≥3 跑 storyboard-build(Stage 4)") + print(f"[done] self-eval.json 模板已落({mode_note}):{eval_path}") + if workflow == "collage-broll": + print("[next] agent 逐维打分 → 全维 ≥3 后 GATE A 呈交隐喻清单 → 批准后进 Phase 2 静帧生成(不走 storyboard-build)") + elif workflow == "reversal-ad": + print("[next] agent 逐维打分(四问答案随 GATE A 呈交)→ 全维 ≥3 跑 storyboard-build(Stage 3)") + else: + print("[next] agent 逐维打分 → 任一维 <3 必返工 → 全维 ≥3 跑 storyboard-build(Stage 3)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py index b08555c5..7bf7f7b9 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py @@ -1,49 +1,40 @@ #!/usr/bin/env python3 -"""Stage 3 — script-write:故事 → 分场剧本。 +"""Stage 1 — script-write:按 Brief(及其指定的 workflow)生产 script(分场剧本)。 Usage: python3 scripts/script-write.py -入:project_dir/script/story.md(Stage 2) -出:project_dir/script/script.md(分场剧本,含 enhancement_cues 六型 + delivery_cues) +入:project_dir/brief.md(甲方 Brief) +出:project_dir/script/script.md(按 workflow 变体) -剧本硬约束: -- 同时间同地点分一场 -- 可拍化描述(不写不可见物) -- 对白引号格式统一 -- enhancement_cues 六型:动作/表情/环境/心理/节奏/视觉锚点 -- delivery_cues:交付旁白时的语气/语速/重音指令(后续 awk-tts / 内置 TTS 用) +变体(脚手架读 Brief 的 workflow 字段与口播交付自动选择): +- 未指定 workflow → 通用分场剧本(可拍化描述 + enhancement_cues 六型 + delivery_cues) +- narration-video + 口播稿 → 口播稿原样落稿锁定(不重写,只做声画实现) +- narration-video + 真人录音 → 录音排布计划(时间戳来自 Stage 11 场景 D ASR) +- narration-video + 旁白 → 旁白稿(由我写,GATE A 交审) +- reversal-ad → 三段结构反转剧本(解说旁白由我写) +- collage-broll → 隐喻清单(本类型的 script) + +Brief 创意不足以直接写剧本 → 先走 story-develop intake workflow 与甲方收敛 Brief。 """ import argparse -import json import sys from pathlib import Path +import _brief -def main() -> None: - parser = argparse.ArgumentParser(description="Stage 3 script-write") - parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") - args = parser.parse_args() - - project = Path(args.project_dir).resolve() - story_path = project / "script" / "story.md" - if not story_path.is_file(): - die(f"前置缺失: story.md 不存在,先跑 story-develop(Stage 2)") - script_path = project / "script" / "script.md" - script_path.parent.mkdir(parents=True, exist_ok=True) +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) - # checkpoint - if script_path.is_file(): - print(f"[checkpoint] script.md 已存在,沿用:{script_path}") - return - stub = f"""# 分场剧本(Stage 3) +GENERIC_STUB = """# 分场剧本(Stage 1 · 通用) -> 据故事梗概({story_path.name})拆成可拍化分场剧本。每场含:场景描述、出场人物、对白、动作、enhancement_cues、delivery_cues。 +> 据 Brief 创意拆成可拍化分场剧本。每场含:场景描述、出场人物、对白、动作、enhancement_cues、delivery_cues。 -## 场 1:(场名,如"主角家中—清晨") +## 场 1:(场名,如"主角家中—清晨") ### 场景描述 > agent 填。可拍化——只写镜头能看到的。不写"她想起了童年"(不可见),改写"她抚摸旧照片,眼神放空"。 @@ -66,17 +57,134 @@ def main() -> None: ### delivery_cues(旁白指令,后续 awk-tts / 内置 TTS 用) > agent 填。语气 / 语速 / 重音 / 情感控制。 -- 语气:(agent 填,如"怀念") -- 语速:(agent 填,如"慢") -- 重音:(agent 填,如"童年") -- 情感控制:(agent 填,如"用怀念温暖的语气",传 awk-tts --context-text) ## 场 2(如有) (agent 同上填) """ - script_path.write_text(stub, encoding="utf-8") + +NARRATION_STUB = """# 旁白稿(Stage 1 · narration-video · TTS 旁白形态) + +> 旁白(TTS 配音解说)文稿由我据 Brief 创意写,GATE A 交审——口播稿才由甲方出,本形态无口播交付。 +> 声音规范:音色按 Brief(未指定选与内容气质匹配的,备选+理由记 decisions.json);语速默认 6–8 字/秒。 + +## 旁白全文(agent 填,逐句可独立配画面) + +(句 1) +(句 2) +… + +### delivery_cues(旁白指令,后续 awk-tts / 内置 TTS 用) +- 语气 / 语速 / 重音 / 情感控制(agent 填) +""" + +RECORDING_STUB = """# 口播录音合成计划(Stage 1 · narration-video · 真人录音形态) + +> 甲方口播录音已定稿(口播稿落稿锁定,不重写);Stage 11 场景 D 跑 narration-align +> 拿 utterance 级真实时间戳,按时间戳排画面——不重配旁白、不改录音内容。 + +## 时间轴(agent 填:句 → 时间段 → 对应画面) +| 句 | 起止(ASR 后回填) | 画面 slot / 素材 | +|----|--------------------|------------------| +| 1 | | | +""" + +REVERSAL_STUB = """# 反转植入剧本(Stage 1 · reversal-ad) + +> 解说旁白由我写(旁白归 CP),第三人称解说体:避免问句、感叹号、第二人称与促销信号词; +> 句长 10–15 字,语速 7–8 字/秒。反转点落在总时长 55%–76%;反转前零产品提及。 +> GATE A 四问(script-self-eval 同查):铺垫可信吗 / 幅度够吗 / 接入因果一句话可复述吗 / CTA 是剧情动机吗。 + +## 解说正文(63%–76%:自洽、有冲突、有悬念;段尾停在求助/任务/待发/冲突节点) +(agent 填) + +## 反转过渡(3%–13%:一句话或一帧把剧情指向产品,双关或因果可追,口播明写因果) +(agent 填) + +## 产品植入(15%–27%:3–4 句单点深打,覆盖 Brief 允许的 ≥3 个价值点,每点有对应画面) +(agent 填) + +## 片尾 CTA(优先二次反转剧情化承载) +(agent 填) +""" + +COLLAGE_STUB = """# 隐喻清单(Stage 1 · collage-broll——本类型的 script) + +> 把每条文稿压成一个 sharp visual idea:一条文稿只做一个隐喻,3–6 个关键物件; +> 不要把文稿逐字放进画面。色彩按语义色场表选(Phase 2)。逐条格式: + +1. 核心意思:(观众最终要看懂什么) + 视觉隐喻:(一句话视觉命题) + 关键物件:(3–6 个) + 色彩:(底色 + 点色,按语意) + 组装顺序:(元素滑入次序) +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Stage 1 script-write(按 Brief 的 workflow 变体)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + brief_path = project / "brief.md" + if not brief_path.is_file(): + die(f"前置缺失: brief.md 不存在,先完成 Stage 0 Brief intake(创意模糊时走 story-develop intake workflow)") + brief_text = brief_path.read_text(encoding="utf-8") + + workflow = _brief.parse_workflow(brief_text) + vo_mode, vo_path = _brief.detect_voiceover(project, brief_text) + + script_path = project / "script" / "script.md" + script_path.parent.mkdir(parents=True, exist_ok=True) + + # checkpoint + if script_path.is_file(): + print(f"[checkpoint] script.md 已存在,沿用:{script_path}") + print("[hint] 要改就手改或删掉重跑本命令(产物文件存在性即 checkpoint)") + return + + if workflow == "narration-video" and vo_mode == "voiceover": + # 口播稿原样落稿锁定:不重写、不优化、不增删卖点 + content = vo_path.read_text(encoding="utf-8") + script_path.write_text(content, encoding="utf-8") + print(f"[mode] narration-video · 口播稿(甲方交付 {vo_path.name})——原样落稿锁定") + print(f"[done] 口播稿已原样拷入:{script_path}(不重写、不顺手优化、不增删卖点)") + print("[next] 跑 script-self-eval(Stage 2)——落稿锁定模式只检查不改写,问题报甲方;GATE A 呈交后进 Stage 3") + return + + if workflow == "narration-video" and vo_mode == "recording": + print("[mode] narration-video · 真人录音——口播稿落稿锁定(录音即定稿),本文件只记排布计划") + script_path.write_text(RECORDING_STUB, encoding="utf-8") + print(f"[done] 录音排布计划模板已落:{script_path}") + print("[next] agent 填时间轴(时间戳待 Stage 11 场景 D ASR)→ 跑 script-self-eval(Stage 2)") + return + + if workflow == "narration-video": + print("[mode] narration-video · TTS 旁白——旁白稿由我写(口播稿才归甲方),GATE A 交审") + script_path.write_text(NARRATION_STUB, encoding="utf-8") + print(f"[done] 旁白稿模板已落:{script_path}") + print("[next] agent 填旁白全文 + delivery_cues → 跑 script-self-eval(Stage 2)") + return + + if workflow == "reversal-ad": + print("[mode] reversal-ad——解说旁白由我写,按三段结构出剧本;GATE A 附四问答案") + script_path.write_text(REVERSAL_STUB, encoding="utf-8") + print(f"[done] 反转植入剧本模板已落:{script_path}") + print("[next] agent 按三段结构填剧本 → 跑 script-self-eval(Stage 2,四问质检)→ GATE A 交审") + return + + if workflow == "collage-broll": + print("[mode] collage-broll——script 即隐喻清单(见 workflows/collage-broll.md Phase 1)") + script_path.write_text(COLLAGE_STUB, encoding="utf-8") + print(f"[done] 隐喻清单模板已落:{script_path}") + print("[next] agent 填隐喻清单 → 跑 script-self-eval(Stage 2,隐喻自检)→ GATE A 呈交 →") + print(" 批准后进 Phase 2 静帧生成(Stage 3–9 由其替代,不走 storyboard-build)") + return + + print("[mode] 未指定 workflow——通用分场剧本;叙事 / 动效 / 蒙太奇手法由我据创意自定") + script_path.write_text(GENERIC_STUB, encoding="utf-8") print(f"[done] script.md 模板已落:{script_path}") - print(f"[next] agent 填剧本 → 跑 script-self-eval(Stage 3b)") + print("[next] agent 填剧本 → 跑 script-self-eval(Stage 2)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py index e1641499..e5b4af4f 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Stage 5 — shot-decompose:每镜拆首帧静照 / 尾帧静照 / 运动描述。 +"""Stage 4 — shot-decompose:每镜拆首帧静照 / 尾帧静照 / 运动描述。 Usage: python3 scripts/shot-decompose.py -入:project_dir/storyboard/storyboard.json(Stage 4) +入:project_dir/storyboard/storyboard.json(Stage 3) 出:project_dir/storyboard/shot_decompose.json(每镜 first_frame/last_frame 文字描述 + motion + variation_type) variation_type 三档(定传给 aigc-video-gen 的参考图数): @@ -20,18 +20,28 @@ import sys from pathlib import Path +import _brief + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + VALID_VARIATIONS = {"static", "dynamic", "transition"} def main() -> None: - parser = argparse.ArgumentParser(description="Stage 5 shot-decompose") + parser = argparse.ArgumentParser(description="Stage 4 shot-decompose") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 4 shot-decompose"): + return board_path = project / "storyboard" / "storyboard.json" if not board_path.is_file(): - die(f"前置缺失: storyboard.json 不存在,先跑 storyboard-build(Stage 4)") + die(f"前置缺失: storyboard.json 不存在,先跑 storyboard-build(Stage 3)") decompose_path = project / "storyboard" / "shot_decompose.json" decompose_path.parent.mkdir(parents=True, exist_ok=True) @@ -47,7 +57,7 @@ def main() -> None: shots = board.get("shots", []) stub = { - "stage": 5, + "stage": 4, "decompose": [], "instruction": "agent 为 storyboard.json 每镜拆首尾帧文字描述 + 运动 + variation_type。运动描述禁角色名用外观特征。", "decompose_schema": { @@ -62,7 +72,7 @@ def main() -> None: } decompose_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] shot_decompose.json 模板已落:{decompose_path}") - print(f"[next] agent 填每镜首尾帧+运动+variation_type → 跑 character-register(Stage 6)") + print(f"[next] agent 填每镜首尾帧+运动+variation_type → 跑 character-register(Stage 5)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py index de13cf15..7741725c 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Stage 9a — slideshow-risk:六维幻灯风险打分(pre-compose 闸门)。 +"""Stage 8 — slideshow-risk:六维幻灯风险打分(pre-compose 闸门)。 Usage: python3 scripts/slideshow-risk.py -入:project_dir/slots/asset-resolve.json(Stage 8,素材齐) +入:project_dir/slots/asset-resolve.json(Stage 7,素材齐) 出:project_dir/slots/slideshow-risk.json(六维分 + verdict) 六维(每维 0–10,加权总分 ≥4.0 才许进 compose,<4.0 fail 必换素材): @@ -23,6 +23,14 @@ import sys from pathlib import Path +import _brief + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + RISK_DIMS = [ ("motion_density", "动镜头占比", "静图不算", 0.25), ("shot_variation", "镜种多样性", "特写/中景/远景/航拍/手持混", 0.15), @@ -35,11 +43,13 @@ def main() -> None: - parser = argparse.ArgumentParser(description="Stage 9a slideshow-risk") + parser = argparse.ArgumentParser(description="Stage 8 slideshow-risk") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 8 slideshow-risk"): + return resolve_path = project / "slots" / "asset-resolve.json" if not resolve_path.is_file(): die(f"前置缺失: asset-resolve.json 不存在") @@ -55,7 +65,7 @@ def main() -> None: return stub = { - "stage": "9a", + "stage": "8", "dims": [ {"key": k, "name": n, "criteria": c, "weight": w, "score": None, "note": ""} for k, n, c, w in RISK_DIMS @@ -71,7 +81,7 @@ def main() -> None: } risk_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] slideshow-risk.json 模板已落:{risk_path}") - print(f"[next] agent 填六维分 → fail 必返工 → pass 跑 delivery-promise-lock(Stage 9b)") + print(f"[next] agent 填六维分 → fail 必返工 → pass 跑 delivery-promise-lock(Stage 9)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py index 4d132185..4e8db158 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Stage 7 — slot-plan:素材 slot 规划。 +"""Stage 6 — slot-plan:素材 slot 规划。 Usage: python3 scripts/slot-plan.py -入:project_dir/storyboard/shot_decompose.json(Stage 5)+ script/intent.json(tone) +入:project_dir/storyboard/shot_decompose.json(Stage 4) 出:project_dir/slots/slot-plan.json(每镜对应 slot:template + hero slot + tone→slot 数) template:slot 模板(如"主角家中—晨光—白T青年") @@ -24,6 +24,14 @@ import sys from pathlib import Path +import _brief + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + TONE_SLOT_TABLE = { "elegy": {"shot_duration": 4.0, "slots_per_min": 15}, "solemn": {"shot_duration": 3.5, "slots_per_min": 17}, @@ -34,14 +42,15 @@ def main() -> None: - parser = argparse.ArgumentParser(description="Stage 7 slot-plan") + parser = argparse.ArgumentParser(description="Stage 6 slot-plan") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--tone", default=None, choices=sorted(TONE_SLOT_TABLE), help="调性,不传走 narrative 默认") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 6 slot-plan"): + return decompose_path = project / "storyboard" / "shot_decompose.json" - intent_path = project / "script" / "intent.json" if not decompose_path.is_file(): die(f"前置缺失: shot_decompose.json 不存在") @@ -55,12 +64,11 @@ def main() -> None: print(json.dumps(existing, ensure_ascii=False, indent=2)) return - intent = json.loads(intent_path.read_text(encoding="utf-8")) if intent_path.is_file() else {} - tone = args.tone or "solemn" # narrative 默认庄重 + tone = args.tone or "solemn" # 默认庄重;agent 据 Brief 创意/调性传 --tone tone_cfg = TONE_SLOT_TABLE[tone] stub = { - "stage": 7, + "stage": 6, "tone": tone, "tone_config": tone_cfg, "slots": [], @@ -79,12 +87,12 @@ def main() -> None: "query": "(API 搜索关键词,英文给 pexels/pixabay)", "tone_params": {"slot_duration": tone_cfg["shot_duration"]}, "hero_slot": False, - "fallback": "静图(siliconflow-img-gen)", + "fallback": "静图(awk-img-gen)", }, } plan_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] slot-plan.json 模板已落:{plan_path}") - print(f"[next] agent 填 slot schema → 跑 asset-resolve(Stage 8)") + print(f"[next] agent 填 slot schema → 跑 asset-resolve(Stage 7)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py deleted file mode 100644 index 582ec67d..00000000 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Stage 2 — story-develop:idea → 故事(分场)。 - -Usage: - python3 scripts/story-develop.py - -入:project_dir/script/intent.json(Stage 0) -出:project_dir/script/story.md(100–200 词梗概 + 人物 + 分场) - -场次划分原则:同时间同地点分一场。agent 按 SKILL.md 工作流填 story.md 模板。 -""" - -import argparse -import json -import sys -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser(description="Stage 2 story-develop") - parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") - args = parser.parse_args() - - project = Path(args.project_dir).resolve() - intent_path = project / "script" / "intent.json" - if not intent_path.is_file(): - die(f"前置缺失: intent.json 不存在,先跑 intent-router(Stage 0)") - - story_path = project / "script" / "story.md" - story_path.parent.mkdir(parents=True, exist_ok=True) - - # checkpoint - if story_path.is_file(): - print(f"[checkpoint] story.md 已存在,沿用:{story_path}") - return - - intent = json.loads(intent_path.read_text(encoding="utf-8")) - stub = f"""# 故事梗概(Stage 2) - -## 档位 -{intent['genre']}(时长目标 {intent['duration_target']}s,{intent['shot_count']['min']}-{intent['shot_count']['max']} 镜) - -## 受众 -{intent['audience']} - -## 梗概(100–200 词) -> agent 填。包含:开场钩子、主角、冲突、转折、结局。显式复述受众与类型(如"本片面向科技爱好者,类型为叙事短片")。 - -## 人物 -> agent 填。每个主要人物:姓名/称呼、年龄、身份、一句话性格、视觉锚点(外观特征,后续 character-register Stage 6 用)。 - -| 人物 | 年龄 | 身份 | 性格 | 视觉锚点 | -|------|------|------|------|---------| -| (agent 填) | | | | | - -## 分场(同时间同地点分一场) -> agent 填。每场:地点、时间、出场人物、核心动作、叙事目的。 - -### 场 1 -- 地点:(agent 填) -- 时间:(agent 填) -- 出场人物:(agent 填) -- 核心动作:(agent 填) -- 叙事目的:(agent 填,如"建立主角日常") - -### 场 2(如有) -(agent 填) -""" - story_path.write_text(stub, encoding="utf-8") - - print(f"[done] story.md 模板已落:{story_path}") - print(f"[next] agent 填梗概/人物/分场 → 跑 script-write(Stage 3)") - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py index 2186f167..b356332c 100644 --- a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Stage 4 — storyboard-build:剧本 → 镜头表。 +"""Stage 3 — storyboard-build:剧本 → 镜头表。 Usage: python3 scripts/storyboard-build.py -入:project_dir/script/script.md(Stage 3,self-eval 全维 ≥3) +入:project_dir/script/script.md(Stage 1,self-eval 全维 ≥3) 出:project_dir/storyboard/storyboard.json(镜头表,每镜叙事目的/机位复用/位置朝向/不写不可见) 硬规则六条: @@ -21,13 +21,22 @@ import sys from pathlib import Path +import _brief + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + def main() -> None: - parser = argparse.ArgumentParser(description="Stage 4 storyboard-build") + parser = argparse.ArgumentParser(description="Stage 3 storyboard-build") parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() + if _brief.collage_guard(project, "Stage 3 storyboard-build"): + return script_path = project / "script" / "script.md" if not script_path.is_file(): die(f"前置缺失: script.md 不存在") @@ -43,9 +52,9 @@ def main() -> None: return stub = { - "stage": 4, + "stage": 3, "shots": [], - "instruction": "agent 据剧本拆镜,每镜按下 schema 填。镜数应在 intent.json 的 shot_count.min-max 区间。", + "instruction": "agent 据剧本拆镜,每镜按下 schema 填。", "shot_schema": { "id": "shot-01", "scene": 1, @@ -62,7 +71,7 @@ def main() -> None: } board_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] storyboard.json 模板已落:{board_path}") - print(f"[next] agent 填镜头表 → 跑 shot-decompose(Stage 5)") + print(f"[next] agent 填镜头表 → 跑 shot-decompose(Stage 4)") if __name__ == "__main__": diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh b/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh index 40f44668..4d283a28 100755 --- a/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh +++ b/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh @@ -22,22 +22,21 @@ video-producer — 视频制作原子能力(wrapper,expert-video 包内工 video-producer help 列可用子命令 流程: - 通用制作流程(expert-video SKILL.md 的 Stage 0→14 + 两闸门)是做**任何**视频都要遵循的基准, + 通用制作流程(expert-video SKILL.md 的 Stage 0→15 + 两闸门)是做**任何**视频都要遵循的基准, 不是"没指定类型时的备选"。Brief 指定 workflow 时,先读包内 workflows/.md, - 按其阶段裁剪调用下列子命令;未指定时只按通用制作流程走,由 intent-router 定档位。 + 按它生产 script 并按它自检(其制作约定在该类型上生效);未指定时只按通用制作流程走。Brief 创意不足以直接写剧本时, + 先走 story-develop intake workflow(workflows/story-develop.md)与甲方收敛 Brief,再进 script-write。 子命令(按阶段序): - intent-router Stage 1 意图路由 → 三档脚本模板(故事讲述型/纯画面动效型/蒙太奇剪接型) reference-concepts 可选 吃甲方给的参考拆解报告出 2–3 差异化概念 - story-develop Stage 2 idea → 故事(分场) - script-write Stage 3 故事 → 分场剧本(含 enhancement_cues + delivery_cues) - script-self-eval Stage 3 脚本自评 N 维打分 - storyboard-build Stage 4 剧本 → 镜头表 - shot-decompose Stage 5 每镜拆首尾帧 + 运动描述 + variation_type - character-register Stage 6 角色三视图 + static/dynamic features 拆分 - slot-plan Stage 7 素材 slot 规划 - asset-resolve Stage 8 按 slot 拉素材(Fast path 人核缩略图) - slideshow-risk Stage 9 六维幻灯风险打分(pre-compose 闸门) + script-write Stage 1 Brief 创意 → 分场剧本(含 enhancement_cues + delivery_cues) + script-self-eval Stage 2 脚本自评 N 维打分 + storyboard-build Stage 3 剧本 → 镜头表 + shot-decompose Stage 4 每镜拆首尾帧 + 运动描述 + variation_type + character-register Stage 5 角色三视图 + static/dynamic features 拆分 + slot-plan Stage 6 素材 slot 规划 + asset-resolve Stage 7 按 slot 拉素材(Fast path 人核缩略图) + slideshow-risk Stage 8 六维幻灯风险打分(pre-compose 闸门) delivery-promise-lock Stage 9 交付承诺八类锁定 render-shot Stage 10 按 slot 渲染(AIGC i2v / 静图) motion-graphics Stage 10 程序化逐帧动态图形(声明式 spec;产品段动效/标题动画,与 render-shot 并列的第二条渲染路径) @@ -51,9 +50,9 @@ video-producer — 视频制作原子能力(wrapper,expert-video 包内工 assemble Stage 12 按镜顺序拼接成片 + 转场 + 规格归一化 + 守卫断言(--manifest/--verify-fps/--expect-durations) add-silent-audio Stage 12 给无音频的视频片段补静音音轨(concat 前置) make-outro Stage 12 片尾制作(形象图+黑边+烧字幕+静音轨 → 标准比例片尾) - motion-audit Stage 13 motion_led 抽查(补公共 video-review) + motion-audit Stage 13b motion_led 抽查(补公共 video-review) normalize Stage 13c 响度归一化到 -14 LUFS(**必跑**) - make-cover Stage 14 封面(siliconflow-img-gen,必含封面主文案) + make-cover Stage 14a 封面(awk-img-gen,必含封面主文案) 后期处理(可选,全部干湿分离:输出落 _<处理名>.mp4,不覆盖输入): burn-srt libass 把 SRT 硬烧进画面(甲方要字幕时) @@ -61,7 +60,7 @@ video-producer — 视频制作原子能力(wrapper,expert-video 包内工 denoise afftdn / arnndn 去环境噪声(仅甲方素材音质差时) interp minterpolate 补帧到 30/60fps(仅低 fps 源材) -闸门不是子命令——GATE A(Stage 6 后文本闸门)与 GATE B(Stage 9 后素材闸门)由 agent +闸门不是子命令——GATE A(Stage 5 后文本闸门)与 GATE B(Stage 9 后素材闸门)由 agent 按 expert-video SKILL.md 执行:呈交摘要 → 结束本轮回复 → 等甲方(main agent 或用户)逐闸门批准。 产物文件存在性即 checkpoint:每个子命令先查产物文件是否存在,存在则 load 不重生成。 diff --git a/crews/content-producer/skills/expert-video/workflows/collage-broll.md b/crews/content-producer/skills/expert-video/workflows/collage-broll.md index ca65c90d..3e8346e8 100644 --- a/crews/content-producer/skills/expert-video/workflows/collage-broll.md +++ b/crews/content-producer/skills/expert-video/workflows/collage-broll.md @@ -1,83 +1,58 @@ # Workflow:Collage B-roll(纸拼贴组装动画) -Brief 里写 `workflow: collage-broll`,或甲方要"把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴"时使用。把一句约 5 秒的口播压成一个 sharp visual idea,再做成高级编辑风纸拼贴组装动画。 +Brief 里写 `workflow: collage-broll`,或甲方要"把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴"时使用。本文指导**从 Brief 生产纸拼贴 B-roll 的 script**(隐喻清单)及这一步之后的自检(隐喻自检,GATE A 质检标准),并附该类型的制作约定(方法论移植自 gbro-collage-broll:半调纸拼贴 + assemble-from-empty);闸门收敛为 GATE A / GATE B 两道——**GATE A 检隐喻清单(即本类型的 script),GATE B 检静帧 contact sheet(即素材)**。不替代通用流程;原子能力、护栏、工作区与交付约定照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 -本文是**通用制作流程在纸拼贴 B-roll 上的细化**(三道闸门、隐喻与静帧规范、Gate 3 批量调度),不替代通用流程;原子能力、护栏、工作区与交付约定照 `expert-video` 的 SKILL.md 执行。 +## 类型定义 -## 三道闸门 +把一句约 5 秒的口播文稿压成一个 sharp visual idea,做成高级编辑风**半调纸拼贴(halftone paper-collage)组装动画** B-roll: -| 闸门 | 停在哪 | 交付给甲方看什么 | -|------|--------|-----------------| -| Gate 1 隐喻确认 | 只设计视觉隐喻,**不生成图片、不生成视频、不调任何视频模型** | 每条的核心意思、情绪、一句话视觉命题、3–6 个关键物件、建议底色与点色、预期组装顺序 | -| Gate 2 静帧确认 | 隐喻确认后才写 visual spec 与 imagegen prompt,用 `siliconflow-img-gen` 生成静帧 | 带编号的静帧 contact sheet + `gate2-qa.md` | -| Gate 3 视频生成 | 静帧确认后不再问用哪个模型,直接调 `collage-broll gate3` 批量跑 i2v | 逐条 contact sheet + 末帧对照 + `gate3-qa.md` + `video-review` 结论 | +- 强烈、平坦、均匀的纯色纸面色场 + 黑白 halftone 照片剪贴为骨架 + 彩色卡纸点缀服务信息层级 +- 动作是 assemble-from-empty:元素从空场逐件滑入、卡位、组装(stop-motion 质感),不是轻微漂移、晃动或慢 zoom +- 默认交付 9:16、5 秒、720×1280、无声 MP4,可直接垫在口播下面 -- 每道闸门都要**结束本轮回复**等甲方批;甲方只确认部分编号时,只让通过的条目进下一道。 -- Gate 1 / Gate 2 分别对应通用制作流程的 GATE A(文本)/ GATE B(素材)语义:付费生成前必停。 -- 甲方已在 Brief 中代理批准某道闸门时,把批准范围记进对应 QA 文件后继续。 +逐条文稿 → 逐条隐喻 → 逐条独立成片。批量隐喻优先形成前后叙事(例如先表现手工消耗与经验流失,再表现规范沉淀与人机分工)。 -## 成功标准 +## 输入契约(Brief 侧) -- 一句话只表达一个清晰隐喻;不要把文稿逐字放进画面。 -- 一条文稿控制在 3–6 个关键物件;元素过多语意变弱,i2v 组装也不稳定。 -- 同一批画面有统一设计语言,但**不强制全部蓝底**。 -- 背景是强烈、平坦、均匀的色场,可按语意变化。 -- 主体以黑白 halftone photographic cut-outs 为骨架;关键卡片、按钮、胶片、规则册等允许红、黄、青、橙、紫、奶油白等彩色纸张。 -- 所有纸片有清晰裁切边、奶油白 keyline、低透明度柔和阴影和纸张颗粒。 -- 动作是 assemble-from-empty,不是轻微漂移、晃动或慢 zoom。 -- 无字幕、无口播全文、无 logo、无水印、无 UI。 -- 默认交付 9:16、5 秒、720×1280、无声 MP4。 +| Brief 字段 | 要求 | +|-----------|------| +| 文稿 | 甲方交付:口播形态给 `voiceover.md`(绝对路径),其余给 Brief 内文稿字段,逐条可独立成句。**落稿锁定不重写**——隐喻是文稿的视觉转译,不是改写;发现文稿无法转译(一句话塞多个隐喻、超时长带)报 Brief owner | +| form | 画幅 / 时长 / 分辨率 / 声音;未指定按默认(9:16、5s、720P、无声) | +| gates | GATE A / GATE B 批准人;代理批准时写明批准范围 | +| acceptance | 验收标准(未指定按「交付」节默认) | -批量隐喻优先形成前后叙事(例如先表现手工消耗与经验流失,再表现规范沉淀与人机分工)。 +## 阶段裁剪表(相对通用制作流程) -## 不适用 - -- 需要精确控制图层、遮挡、镜头穿越或可编辑时间线 → 改用分层动画方案,并向甲方说明本 workflow 做不到。 -- 只要视频提示词、不要成片 → 直接写 prompt 交付,不走本流程。 -- 需要真实人物产品广告或口播演员 → 走 Narration Video,或只按通用制作流程做(不套类型 workflow)。 -- 甲方明确要可逐层修改的透明素材 → 本 workflow 默认不拆透明图层。 - -## 项目目录 - -自建工作区 `output_videos//`(甲方不指定、不代建): - -```text -/ -├── brief.md # 文稿 + Gate 1 隐喻清单 -├── visual-spec.json # Gate 2 视觉规格 -├── imagegen-prompts.md # Gate 2 Seedream prompt 留档 -├── gen-jobs.json # Gate 3 批量调用清单 -├── gate2-qa.md # 静帧 QA 结论 -├── gate3-qa.md # 视频 QA 结论 -├── still-contact-sheet.jpg # Gate 2 静帧总图 -├── video-contact-sheet-all.jpg # Gate 3 全部成片逐秒抽帧 -├── video-first-frame-all.jpg # 全部成片实际首帧(验证真的从空色场开始) -├── end-frame-comparison-all.jpg # 确认静帧 vs 视频末帧并排 -├── 01-/ -│ ├── gen-prompt.txt # aigc-video-gen --prompt 内容 -│ ├── frames/ -│ │ ├── still.png # Gate 2 确认的完成帧(原图) -│ │ ├── last-frame.png # 统一裁到 720x1280 的尾帧 -│ │ └── first-frame.png # 纯色空首帧(同底色 hex) -│ └── gen-runs/run-v01/ -│ ├── final-5s.mp4 # aigc-video-gen 产物(声画同出) -│ ├── final-5s-noaudio.mp4 # 默认交付版(无声) -│ ├── contact-sheet.jpg -│ ├── video-last-frame.jpg -│ └── end-frame-comparison.jpg -└── 02-/... -``` +| Stage | 处置 | 本文对应 | +|-------|------|---------| +| 0 Brief intake | 照走 | 核对文稿逐条可拆、规格齐;缺字段向 Brief owner 澄清 | +| 1 script-write | **重定义** | Phase 1 隐喻设计——产物落 `script/script.md`(隐喻清单),就是本类型的"分场剧本" | +| 2 script-self-eval | **重定义** | 隐喻自检(见 Phase 1),任一条不过必返工 | +| 3–5 storyboard / shot-decompose / character-register | **裁剪** | 单镜固定机位、无人物角色,不建 storyboard / shot_decompose / characters | +| GATE A | 照走 | 呈交隐喻清单 | +| 6–9 slot-plan → delivery-promise-lock | **由静帧生成替代** | Phase 2:visual-spec + Seedream 静帧 + 静帧 QA;AIGC 素材的来源记录 = prompt + 模型 + 生成时间 | +| GATE B | 照走 | 呈交静帧 contact sheet | +| 10 render-shot | **重定义** | Phase 3:i2v 首尾帧组装(`collage-broll render` 批量调度) | +| 11 mix-audio | **裁剪** | 默认无声交付;甲方要 aigc 原声时交付带声版 | +| 12 assemble | **裁剪** | 逐条独立成片,不拼接 | +| 13a video-review | 照走(强制) | 无声版 `audio_absent` warning 是预期,放行;带声版出 `audio_absent` 是 critical | +| 13b motion-audit | 照走 | 抽查组装过程逐件进入而非整体淡入 | +| 13c normalize | 无声片豁免 | 带声版必跑 | +| 14a make-cover | 按需 | B-roll 垫片通常无封面,Brief 要求才做 | +| 15 交付 | 照走 | 回报产物绝对路径 | -## Phase 1:设计视觉隐喻(Gate 1) +## Phase 1 隐喻设计(Stage 1 重定义) -先把文稿压成一个视觉命题,提取: +先把每条文稿压成一个视觉命题,提取: - **核心意思**:观众最终要看懂什么 - **情绪**:冷静、惊讶、紧迫、豁然开朗、荒诞、反讽 - **动作动词**:打开、连接、漏掉、装订、归档、点亮、压缩、分叉、组装 - **可视化隐喻**:机器、时钟、胶片、档案柜、控制台、规则册、漏斗、轨道、棋子 -输出格式: +**不要把文稿逐字放进画面。** 默认一条文稿只做一个隐喻,控制在 3–6 个关键物件;元素过多语意变弱,i2v 组装也不稳定。 + +`script/script.md` 每条格式: ```text 1. 核心意思:经验每次都在重复消耗 @@ -87,9 +62,25 @@ Brief 里写 `workflow: collage-broll`,或甲方要"把这句口播做成拼 组装顺序:时钟 → 人物与剪刀 → 胶片 → 最终短输出 ``` -输出后停下等确认。 +### 隐喻自检(Stage 2 重定义,任一条不过必返工) + +1. 一句话只表达一个清晰隐喻? +2. 关键物件 3–6 个,不是满屏碎片? +3. 文稿没有逐字进画面(无字幕、无口播全文)? +4. 底色与点色按语义色场表选,有理由(见 Phase 2 色彩规则)? +5. 批量时前后叙事成立,或每条独立成立? + +## GATE A:呈交隐喻清单 + +文本闸门——**停,结束本轮回复**,发 Brief owner 审: + +- 呈交:条数、每条一句话视觉命题、色彩方案、组装顺序概览 +- 甲方只确认部分编号时,只让通过的条目进 Phase 2;未通过条目改隐喻重审 +- 甲方已在 Brief 代理批准时,把批准范围落 `gates/gate-a.md` 后继续 + +## Phase 2 生成静帧(GATE A 批准后) -## Phase 2:生成彩色拼贴静帧(Gate 2) +先写自包含的 `script/visual-spec.json`,再写 imagegen prompt。 ### visual-spec.json @@ -125,13 +116,15 @@ Brief 里写 `workflow: collage-broll`,或甲方要"把这句口播做成拼 主体以黑白半调为主,局部彩色纸张必须服务信息层级,不为彩色而彩色。 -### imagegen prompt(siliconflow-img-gen / Seedream) +### imagegen prompt(awk-img-gen / 百炼) ```bash -siliconflow-img-gen --prompt "<下面整段>" --image-size 1600x2848 --out-dir /01-/frames/ +awk-img-gen --prompt "<下面整段>" --image-size 1536x2688 --out-dir /render//frames/ ``` -Prompt 用英文(Seedream 对英文响应更好): +> 尺寸注意:百炼上限总像素 2048×2048,旧火山 9:16 预设 `1600x2848` 超限会被脚本拒绝;9:16 一律用 `1536x2688`。 + +Prompt 语言:下面模板是英文可照用;qwen-image / wan2.7 中文同样好,**要渲染的文字必须原句完整写入**(见 awk-img-gen 封面最佳实践)。整段落 `script/imagegen-prompts.md` 留档: ```text Use case: ads-marketing @@ -145,33 +138,42 @@ Constraints: [本条隐喻必须一眼看懂的关系]. Avoid: no typography, no readable letters, no numerals, no logos, no watermark, no UI, no subtitles, no glossy 3D, no photoreal environment, no clutter. ``` -Seedream 不支持参考图锁风格,"同设计语言"靠同一批复用同一 `style_signature` 字串 + 同一 `color_field` 范围。 +同设计语言:优先用 awk-img-gen 参考图编辑(`--image` 传同批已过 QA 的静帧,1–3 张)锁风格;不用参考图时靠同一批复用同一 `style_signature` 字串 + 同一 `color_field` 范围。 ### 静帧 QA 检查:隐喻是否一眼看懂 / 主体是否集中 / 是否有假字、logo、水印、UI / 是否保留足够纯色场便于从空场组装 / 是否 3–6 个清晰大组而非满屏碎片 / 同批是否统一质感但有色彩变化。 -通过的原图复制到 `/frames/still.png`,拼 contact sheet 后交甲方确认,结论写 `gate2-qa.md`。要求重生部分静帧时,新版 contact sheet 递增命名(`still-contact-sheet-v2.jpg`),保留旧版便于对比。 +通过的原图复制到 `render//frames/still.png`,拼带编号的 contact sheet。要求重生部分静帧时,新版 contact sheet 递增命名(`still-contact-sheet-v2.jpg`),保留旧版便于对比。QA 结论写 `review/still-qa.md`。 ```bash -ffmpeg -y -pattern_type glob -i "/*/frames/still.png" \ - -vf "scale=270:480,tile=5x1" -frames:v 1 /still-contact-sheet.jpg +ffmpeg -y -pattern_type glob -i "/render/*/frames/still.png" \ + -vf "scale=270:480,tile=5x1" -frames:v 1 /review/still-contact-sheet.jpg ``` 段数 > 5 时分多行(`tile=5x2`、`5x3`…)。 -## Phase 3:i2v 生成视频(Gate 3) +## GATE B:呈交静帧 contact sheet + +素材闸门——**停,结束本轮回复**,发 Brief owner 看: + +- 呈交:静帧 contact sheet(带编号)、静帧 QA 结论、每条色彩与隐喻的对应关系 +- 部分通过:只让通过的条目进 Phase 3;要改的静帧重生并重新确认 +- 授权与来源记录一并呈交(AIGC 素材 = prompt + 模型 + 生成时间,留档见工作区) +- 甲方已在 Brief 代理批准时,把批准范围落 `gates/gate-b.md` 后继续 + +## Phase 3 i2v 组装视频(Stage 10 重定义) ### 1. 准备首尾帧 ```bash # 尾帧:确认静帧统一裁到 720x1280 -ffmpeg -y -i /frames/still.png \ +ffmpeg -y -i render//frames/still.png \ -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \ - /frames/last-frame.png + render//frames/last-frame.png # 首帧:与尾帧同底色的纯色空纸面(assemble-from-empty 的核心) -ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 -frames:v 1 /frames/first-frame.png +ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 -frames:v 1 render//frames/first-frame.png ``` 甲方明确要求不从完全空白开始时,首帧才保留一个基础物件。 @@ -188,11 +190,11 @@ ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 -frames:v 1 /frames/first ### 3. 批量生成 -写 `gen-jobs.json`(每条含 `prompt` / `first_frame` / `last_frame` / `output` / `ratio` / `resolution` / `duration`),然后: +写 `render/gen-jobs.json`(每条含 `prompt` / `first_frame` / `last_frame` / `output` / `ratio` / `resolution` / `duration`),然后: ```bash -collage-broll gate3 --batch /gen-jobs.json -collage-broll gate3 --batch /gen-jobs.json --dry-run # 先看调度计划 +collage-broll render --batch /render/gen-jobs.json +collage-broll render --batch /render/gen-jobs.json --dry-run # 先看调度计划 ``` 内部串行逐条调公共 `aigc-video-gen` i2v(视频生成是异步轮询任务,并行会撞平台并发限),候选链 fallback 与 decisions.log 由 `aigc-video-gen` 自带。退出码 2 = 部分 job 失败:只重跑失败条目,已通过的不重跑。 @@ -204,17 +206,19 @@ collage-broll gate3 --batch /gen-jobs.json --dry-run # 先看调 ### 4. 强制无声交付 ```bash -ffmpeg -y -i /final-5s.mp4 -map 0:v:0 -c:v copy -an /final-5s-noaudio.mp4 +ffmpeg -y -i render//gen-runs/run-v01/final-5s.mp4 -map 0:v:0 -c:v copy -an \ + render//gen-runs/run-v01/final-5s-noaudio.mp4 ``` -默认交付 `final-5s-noaudio.mp4`,保留 `final-5s.mp4` 作中间产物。甲方明确要带声时直接交付 `final-5s.mp4`。 +默认交付 `final-5s-noaudio.mp4`,保留 `final-5s.mp4` 作中间产物(aigc 声画同出的原声版;甲方明确要带声时直接交付它)。 -## 视频 QA +## 视频 QA(Stage 13) 不要只看尾帧,必须检查组装过程与最终落位。 ```bash -ffmpeg -y -i /final-5s-noaudio.mp4 -vf "fps=1,scale=270:480,tile=5x1" -frames:v 1 /contact-sheet.jpg +ffmpeg -y -i render//gen-runs/run-v01/final-5s-noaudio.mp4 \ + -vf "fps=1,scale=270:480,tile=5x1" -frames:v 1 render//gen-runs/run-v01/contact-sheet.jpg ``` 通过标准: @@ -226,14 +230,14 @@ ffmpeg -y -i /final-5s-noaudio.mp4 -vf "fps=1,scale=270:480,tile=5x1" -fram - 最终帧与确认静帧一致;轻微姿态或细节漂移只要不影响隐喻语义即判通过,不为此重跑 - 成片为 720×1280、5 秒 -另抽视频末帧与确认静帧并排生成 `end-frame-comparison.jpg`;批量项目再合三张总览图(`video-contact-sheet-all.jpg` / `video-first-frame-all.jpg` / `end-frame-comparison-all.jpg`)。逐条 QA 结论(含带瑕疵通过的理由)写 `gate3-qa.md`。 +另抽视频末帧与确认静帧并排生成 `end-frame-comparison.jpg`;批量项目再合三张总览图落 `review/`(`video-contact-sheet-all.jpg` / `video-first-frame-all.jpg` / `end-frame-comparison-all.jpg`)。逐条 QA 结论(含带瑕疵通过的判定理由)写 `review/collage-video-qa.md`。 ### 技术自检(强制) 视觉 QA 后必须再跑公共 `video-review`,verdict=pass 才交付: ```bash -video-review /final-5s-noaudio.mp4 +video-review render//gen-runs/run-v01/final-5s-noaudio.mp4 ``` 视觉 QA 评美与语义,`video-review` 评技术合规(ffprobe 全字段 / 抽帧黑帧扫 / 音频电平 / 时长分辨率一致性),互补不重叠。fail 按 critical 项修或重生对应 job;warn 向甲方复述由其决定。 @@ -251,12 +255,46 @@ video-review /final-5s-noaudio.mp4 | 个别视频失败 | 只重跑对应 job | | i2v 报错 | 查首尾帧是否 720x1280、是否真存在、workdir 是否 workspace 根 | +## 工作区 + +自建工作区 `output_videos//`(甲方不指定、不代建),在通用流程标准树上裁剪: + +```text +/ +├── brief.md / voiceover.md # 甲方交付(Brief + 落稿锁定文稿) +├── script/ +│ ├── script.md # Phase 1 隐喻清单(本类型的"分场剧本") +│ ├── visual-spec.json # Phase 2 视觉规格 +│ ├── imagegen-prompts.md # imagegen prompt 留档 +│ └── decisions.json # 决策审计链 +├── gates/ +│ ├── gate-a.md / gate-b.md # 闸门批准记录(含批准人与批准范围) +├── render/ +│ ├── gen-jobs.json # Phase 3 批量 i2v 调用清单 +│ └── / +│ ├── frames/ # still.png(确认原图)/ last-frame.png / first-frame.png +│ └── gen-runs/run-v01/ # final-5s.mp4 / final-5s-noaudio.mp4 / contact-sheet.jpg / +│ # video-last-frame.jpg / end-frame-comparison.jpg +└── review/ + ├── still-qa.md # GATE B 静帧 QA 结论 + ├── still-contact-sheet.jpg # GATE B 呈交物(重生递增 v2/v3…) + ├── collage-video-qa.md # Phase 3 逐条视频 QA 结论 + └── video-contact-sheet-all.jpg / video-first-frame-all.jpg / end-frame-comparison-all.jpg +``` + ## 交付 -- 每条 `/gen-runs/run-v01/final-5s-noaudio.mp4`(甲方要带声则 `final-5s.mp4`) +- 每条 `render//gen-runs/run-v01/final-5s-noaudio.mp4`(甲方要带声则 `final-5s.mp4`) - 每条 contact sheet、批量总 contact sheet、末帧对照图 -- `gate2-qa.md` / `gate3-qa.md` / `video-review` 结论 +- `review/still-qa.md` + `review/collage-video-qa.md` + `video-review` 结论 - 一句说明每条文稿如何转成视觉隐喻 - 回报产物**绝对路径** 成片问题来自 i2v 生成限制(组装感弱 / 尾帧漂移)时直接说明;只有需要精确图层控制时才建议换方案,并向甲方报清代价。 + +## 不适用 + +- 需要精确控制图层、遮挡、镜头穿越或可编辑时间线 → 改用分层动画方案,并向甲方说明本 workflow 做不到 +- 只要视频提示词、不要成片 → 直接写 prompt 交付,不走本流程 +- 需要真实人物产品广告或口播演员 → 走 Narration Video,或按通用制作流程做 +- 甲方明确要可逐层修改的透明素材 → 本 workflow 默认不拆透明图层 diff --git a/crews/content-producer/skills/expert-video/workflows/narration-video.md b/crews/content-producer/skills/expert-video/workflows/narration-video.md index ab237260..f275a4e8 100644 --- a/crews/content-producer/skills/expert-video/workflows/narration-video.md +++ b/crews/content-producer/skills/expert-video/workflows/narration-video.md @@ -1,45 +1,27 @@ # Workflow:Narration Video(口播类视频) -Brief 里写 `workflow: narration-video`,或 Brief 交付了口播文案 / 真人口播录音时使用。本文是**通用制作流程在口播类视频上的细化**:声画实现套路、阶段裁剪与验收补充,不替代通用流程——阶段链、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 +Brief 里写 `workflow: narration-video`,或 Brief 交付了口播文案 / 真人口播录音时使用。本文指导**从 Brief 生产口播 / 旁白类视频的 script**(口播形态落稿锁定、旁白稿由我写)及这一步之后的自检(GATE A 质检标准),并附该类型的声画实现约定与验收清单——阶段链、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 ## 类型定义 -以**人声讲述**为主干的视频。口播文案是甲方(main agent 或用户)的策略产物,已定稿交付;我负责声画实现——配音或录音处理、字级时间戳对齐、按语义配画面、字幕、BGM、成片与封面。 +以**人声讲述**为主干的视频,分两种声音形态、文稿归属不同:**口播**(真人出镜 / 数字人 / 真人口播录音)的口播稿是甲方(main agent 或用户)的策略产物,随 Brief 交付(`voiceover.md` 或录音文件),我落稿锁定只做声画实现;**旁白**(TTS 配音解说、剪辑配的画外音)的文稿完全由我写(据 Brief 创意),GATE A 交审。我负责配音或录音处理、字级时间戳对齐、按语义配画面、字幕、BGM、成片与封面。 ## 三种输入形态 | 形态 | 甲方交付 | 我做什么 | |------|---------|-----------| -| TTS 旁白 + 画面 | `voiceover.md`(口播终稿)+ 素材(可选) | `awk-tts` 生成旁白(音色按 Brief)→ `narration-align` 拿字级时间戳 → 按时间戳切配画面 → 字幕 → BGM | +| TTS 旁白 + 画面 | 素材(可选);旁白稿**由我写**,GATE A 交审 | `awk-tts` 生成旁白(音色按 Brief)→ `narration-align` 拿字级时间戳 → 按时间戳切配画面 → 字幕 → BGM | | 真人口播录音 + 画面 | 录音文件绝对路径 + 素材(可选) | ASR 拿时间戳(Stage 11 场景 D)→ 按时间戳排画面 → 字幕 → 音质差时降噪 → BGM ducking | | 真人出镜口播录像 | 录像素材绝对路径 | 加字幕、BGM、片头片尾、包装、封面;语义级高光剪辑与去口气词交回 main 的 `talking-head-cut` | ## 硬边界 -- **口播文案原样落稿锁定**:落 `script/script.md`,不重写、不"顺手优化"措辞、不增删卖点。 -- 发现文案有问题(超时长带、合规风险、抽象到无法配画面)→ **报甲方**,不自行改。 -- 模式 B(用户直接对话)下用户只给大意时,可代拟文案,但必须发用户确认,定稿后才算 `voiceover`。 +- **口播稿原样落稿锁定**:口播(真人出镜 / 数字人 / 真人录音)的文稿落 `script/script.md`,不重写、不"顺手优化"措辞、不增删卖点。 +- **旁白稿由我写**:TTS 配音解说的文稿据 Brief 创意起草,GATE A 交审,不找甲方要旁白稿。 +- 口播稿发现问题(超时长带、合规风险、抽象到无法配画面)→ **报甲方**,不自行改。 +- 模式 B(用户直接对话)下用户本人出镜口播、只给大意时,可代拟口播稿,但必须发用户确认,定稿后才算 `voiceover`;旁白无需代拟(本就归我)。 - 语义级剪辑(去口气词、智能剪重点)归 main 的 `talking-head-cut`;我只做几何级修整(切段/拼接/混音/烧字幕/补轨)。 -## 阶段裁剪(对通用制作流程的细化) - -| 阶段 | 本 workflow 的做法 | -|------|--------------------| -| Stage 0 | 除 Brief 外,必须确认口播文案 / 录音**已到位**:绝对路径存在、可解码、时长可读;缺 → 向甲方要,不开工 | -| Stage 1 | 档位一般为故事讲述型;纯画面动效型不适用(口播是主干) | -| Stage 2–3 | 跳过 `story-develop` / `script-write`;口播稿原样落 `script/script.md` | -| Stage 3b | `script-self-eval` 只做检查,不改写:总字数 vs 目标时长(按 6–8 字/秒折算)、句长、合规敏感词、是否存在无法配画面的抽象段 | -| Stage 4 | 分镜按口播段落切:每段"讲什么 → 看什么"写清;不写不可见 | -| Stage 5 | 画面全来自现成素材时跳过;需要 AIGC 补画面时保留 | -| Stage 6 | 无 AIGC 角色时跳过 | -| Stage 7–8 | slot 按口播语义规划;甲方给了素材先入库校验(可解码/规格/授权),缺口才补搜 | -| Stage 9a | 幻灯风险重点查"一句口播一张静图"的幻灯片感 | -| Stage 9b | 交付承诺必须含音画同步与字幕样式 | -| Stage 11 | **核心阶段**:场景 B(TTS 一次性生成 + 字级时间戳)或场景 D(甲方录音 → ASR 时间戳);字幕必须来自对齐后的时间轴 | -| Stage 12 | `timeline-compose` 按字级时间戳对齐:每句口播不得越过对应镜头边界,连续口播保留呼吸间隔;切旁白段用 `clip-trim --pre-buffer 0.5` 防吞首字。甲方要求逐句 TTS(每句独立 mp3)时改走 `narration-layout`(对齐镜头起点 + 防重叠守卫 + 越界断言 + SRT + 混音一步),拼接用 `assemble --manifest --verify-fps` | -| Stage 13 | `video-review` + `motion-audit` + **响度归一化必跑** | -| Stage 14 | 封面主文案来自 Brief;口播金句作候选时需 Brief 允许 | - ## 声音规范 - **音色**:按 Brief 指定;未指定时选与内容气质匹配的音色,把备选 + 置信度 + 理由记 `script/decisions.json`。 @@ -50,7 +32,7 @@ Brief 里写 `workflow: narration-video`,或 Brief 交付了口播文案 / 真 ## 验收检查 -1. 口播与甲方交付逐字一致(除甲方书面同意的修改)。 +1. 口播类:成片口播与甲方交付逐字一致(除甲方书面同意的修改);旁白类:旁白与 GATE A 批准稿一致。 2. 音画同步:每句口播对应画面不越界,无黑屏空转、无长静帧。 3. 字幕与口播一致、无错字、在安全区内。 4. 响度归一化(-14 LUFS)已跑并记录。 diff --git a/crews/content-producer/skills/expert-video/workflows/reversal-ad.md b/crews/content-producer/skills/expert-video/workflows/reversal-ad.md index ab092ad0..91133b18 100644 --- a/crews/content-producer/skills/expert-video/workflows/reversal-ad.md +++ b/crews/content-producer/skills/expert-video/workflows/reversal-ad.md @@ -1,6 +1,6 @@ # Workflow:Reversal Ad(「万万没想到」式反转植入) -Brief 里写 `workflow: reversal-ad` 时使用。本文是**通用制作流程在反转植入类视频上的细化**:三段结构、反转手法、素材与口播约束、阶段裁剪,不替代通用流程——原子能力、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 +Brief 里写 `workflow: reversal-ad` 时使用。本文指导**从 Brief 生产反转植入类视频的 script**(三段结构、反转手法、解说旁白规范)及这一步之后的自检(GATE A 四问质检标准),并附该类型的素材与制作约定——原子能力、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 ## 类型定义 @@ -11,26 +11,27 @@ Brief 里写 `workflow: reversal-ad` 时使用。本文是**通用制作流程 | 段 | 时长占比 | 功能 | 硬要求 | |----|---------|------|--------| | 解说正文 | 63%–76% | 讲一个自洽、有冲突、有悬念的故事 | 主悬念一句话可复述,且贯穿到反转点;段尾停在「求助 / 任务 / 待发 / 冲突」节点 | -| 反转过渡 | 3%–13% | 用一句话或一帧把剧情指向产品 | 必须双关或因果可追,不许硬切;口播明写因果 | +| 反转过渡 | 3%–13% | 用一句话或一帧把剧情指向产品 | 必须双关或因果可追,不许硬切;解说明写因果 | | 产品植入 | 15%–27% | 集中讲产品,讲完立即收尾 | 3–4 句单点深打,覆盖 Brief 允许的 ≥3 个价值点,每点有对应画面;片尾 CTA 优先用二次反转剧情化承载 | 反转点落在总时长 **55%–76%** 之间(集中植入偏前,两段式偏后)。占比是创作约束,不是硬编码参数;Brief 指定时以 Brief 为准。产品植入段末尾可设**二次反转**承载 CTA(见「反转幅度与接入丝滑度」),其时长计入产品植入段,不另立第四段。 -## 反转手法(四选一,或叠加) +## 反转手法(五选一,或叠加) | 手法 | 机制 | 实现要点 | |------|------|----------| | 任务指引式 | 剧情停在求助节点 → 指路者给出答案,答案就是产品 | 「去往 X 就能化解 Y」句式;产品信息用标注贴图/攻略图呈现(箭头 + 关键词),不做广告贴片感 | -| 双关置换式 | 剧情核心意象与产品同名同形 | 意象必须在解说正文里先建立,反转时复用同一意象(沙盘 / 天池 / 秘境之类) | -| 身份彩蛋式 | 主角身份在反转处变成与产品相关的角色 | 身份转换要由剧情自然推出(求职、比赛、任务),不靠旁白硬说 | -| 戏中戏式 | 剧情内的媒介(录像机屏幕、照片、窗外、手机屏)先出现产品画面 | 口播仍讲剧情句,画面先行;随后全屏切产品段 | +| 双关置换式 | 剧情核心意象与产品同名同形 | 意象必须在解说正文里先建立,反转时复用同一意象 | +| 身份彩蛋式 | 主角身份在反转处变成与产品相关的角色 | 身份转换要由剧情自然推出,不靠旁白硬说 | +| 画面转场式 | 素材的某一帧平滑转到产品介绍的首帧(比如说,素材中女主角的眼睛出现倒影,然后放大到产品段首帧) | 转场过渡可以使用 AIGC,同时指定首帧和尾帧。| +| 戏中戏式 | 剧情内的媒介(录像机屏幕、照片、窗外、手机屏)先出现产品画面 | 解说仍讲剧情句,画面先行;随后全屏切产品段 | ## 反转幅度与接入丝滑度(第一质量轴) 实践中最常见的失败不是没有反转,而是**反转幅度不够大、或接入生硬**。两轴缺一不可: - **幅度大**:反转前后两个世界的距离拉到最远——题材跳(现实求助 / 悬念事件 ↔ 商业产品)、身份跳(普通人 ↔ 产品相关角色)、场景跳、情绪跳。铺垫段演得越认真、越沉浸,反转越强;铺垫段禁止自嘲、禁止对镜头挤眼、禁止流露任何「广告感」。 -- **丝滑接入**:接入点必须由剧情自身逻辑自然推出——自问自答、因果链、意象复用。观众事后的反应应是「原来如此,说得通」,而不是「怎么突然打广告」。接入句的因果必须一句话可复述。 +- **丝滑接入**:接入点必须由剧情自身逻辑自然推出——自问自答、因果链、意象复用。观众事后的反应应是「原来如此,说得通」,而不是「怎么突然打广告」。或者就利用画面转场,使用AIGC生成转场片段连接素材和产品段。 - **二次反转(可选,强烈推荐)**:片尾互动号召(转发 / 点赞 / 评论)不做广告口号,种进剧情动机里——观众完成互动时感觉自己在推进剧情,而不是在点广告。 ✅ 正例(真人口播爆款,借结构不照搬措辞):一个人拍视频「我丢了 2000 块钱,大家帮我找找,找到必有重谢」——一转:「我答谢你什么呢?答谢你带你去呼伦贝尔旅游」,身份揭晓:他其实是呼伦贝尔地接导游,宣传的是旅游线路——二转:「这 2000 块对我真的很重要,看到的哥哥姐姐帮我转发一下」,转发号召成了「找钱」剧情的自然延伸。三个可复制点:铺垫严肃可信(现实求助、零广告感)→ 落差巨大;「答谢你什么呢」自问自答桥接两个世界 → 接入丝滑;CTA 由剧情动机给出 → 转发不是打广告。 @@ -48,13 +49,13 @@ GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): 3. 接入句的因果能一句话复述吗(丝滑度)? 4. CTA 也是剧情的一部分吗(设了二次反转时)? -> 正例为真人口播场景,其第二人称口语不受本文口播规范约束;由我代笔第三人称解说体时,CTA 句式仍按口播规范,但 CTA **动机必须由剧情给出**。 +> 正例为真人口播场景,其第二人称口语不受本文解说规范约束;由我代笔第三人称解说体时,CTA 句式仍按解说规范,但 CTA **动机必须由剧情给出**。 ## 叙事与植入规则 - **单线闭环**:主角动机一句话说清并在片内闭环;素材撑不住就改写,不留断头线。 - **节拍 ≤ 6**:每节拍一句,句间因果可追。 -- **钩连句**:转生、化身、置换等转折必须用口播明写因果,并用同色、同物或同动作的画面衔接。 +- **钩连句**:转生、化身、置换等转折必须用解说旁白明写因果,并用同色、同物或同动作的画面衔接。 - **意象桥**:产品段第一镜必须复用解说正文的核心意象;禁止无关风景空镜硬切。 - **反转前零产品提及**:不插 punch 句、不散落卖点、不出现品牌暗示(含 logo、包装、界面)。 - **收束**:产品段讲完立即收尾,不加无信息量的仪式句或氛围空镜。 @@ -62,17 +63,17 @@ GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): - **音效**:可在反转瞬间用 whoosh 等音效标记剧情→产品切换,但不得用音效替代因果衔接。 - **合规**:正片零 URL、零域名、零联系方式;官网、仓库、价格、活动入口只写进 `final-deliver.md` 交甲方(发布说明由甲方拟)。产品能力只讲 Brief 允许的范围,不承诺收益数字。 -## 口播(旁白) +## 解说旁白与口播 -- 甲方交付 `voiceover.md` 时**原样落稿锁定**(Stage 3 不重写策略文案,仍跑 `script-self-eval` 做检查)。 -- 未交付时由我写,GATE A 交审。默认规范:第三人称解说体;避免问句、感叹号、第二人称(你/您/家人们)与促销信号词(「接下来介绍」「这是我们的」);句长 10–15 字,语速 7–8 字/秒。 +- 解说是**旁白**,完全由我写,GATE A 交审。默认规范:第三人称解说体;避免问句、感叹号、第二人称(你/您/家人们)与促销信号词(「接下来介绍」「这是我们的」);句长 10–15 字,语速 7–8 字/秒。 +- 真人出镜 / 数字人**口播**形态(甲方交付 `voiceover.md` 或真人录音)时按口播规则:落稿锁定,Stage 1 `script-write` 原样入 `script.md` 不重写,仍跑 `script-self-eval` 做检查。 - 真人口播录音(甲方提供)走 Stage 11 场景 D:ASR 拿时间戳后按时间戳排素材,不重配旁白。 ## 素材 sourcing 来源模式只允许以下四类(含混合),每段素材都必须能追溯到来源模式与授权记录: -1. **open_license_footage**:首选 **Blender 开源电影**——官网直链 `studio.blender.org/films/…`,无需登录,协议均为 CC BY。已验证可用片库:Spring / Sintel / Caminandes 系列 / Tears of Steel / Big Buck Bunny / Coffee Run。逐部记录原片 URL、许可证与署名要求;**发布简介必须带原片署名**——署名文案写入 `final-deliver.md` 交甲方并入发布说明。片库以外的片源逐案核实许可证允许商用与改编,不得把"网上能下载"等同于可用。 +1. **open_license_footage**:首选 **Blender 开源电影**——官网直链 `studio.blender.org/films/…`,无需登录,协议均为 CC BY。片库实测状态(2026-09 核验):Big Buck Bunny / Sintel / Tears of Steel / Agent 327 官方直链可下;**Caminandes 部分集数直链已 404、Coffee Run 需登录 cloud.blender.org(302)**——选片前先 curl 探测直链可用性再定片,不要默认全片库可下。逐部记录原片 URL、许可证与署名要求;**发布简介必须带原片署名**——署名文案写入 `final-deliver.md` 交甲方并入发布说明。片库以外的片源逐案核实许可证允许商用与改编,不得把"网上能下载"等同于可用。 2. **user_provided**:用户直供现成影视片段。入库**三查**:① `ffprobe` 全片 decode 校验(整片解码,不只读文件头,防尾部损坏);② 记录分辨率、帧率、时长、音轨;③ 来源与授权背景向用户确认,原样记入 `final-deliver.md`。署名按用户提供的信息如实标注,不猜测、不做授权背书;版权风险由甲方确认承担,我只做技术处理。入库副本落 `raw_materials/`。 3. **aigc**:按 Brief 风格边界生成(公共 `aigc-video-gen`)。**每段 3–15s**;prompt 必须含**画面描述 + 音频描述**——AIGC 声画同出,源文件自带音频可作环境音床(见护栏 5)。产物存 `/generations/`(天然满足 `aigc-video-gen` 的 `output_videos/` 路径约束),每段附 metadata(prompt、模型、生成时间)。发布时按平台要求勾「AI 生成」标注——标注要求写入 `final-deliver.md` 交甲方。 4. **mixed**:以上混合,每段素材独立追溯到各自的模式与授权记录。 @@ -94,9 +95,9 @@ GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): | core_message | 本条必须传达的核心信息 | | product_points | 产品/服务事实、允许讲的能力、禁用承诺(只以 Brief 为准,不内置品牌事实) | | implant_cta | Brief「业务植入与 CTA」字段:植入位置与方式、内容与业务的衔接句要求、CTA 主目标与句式;未给时按本 workflow 默认(反转点 55%–76%、植入段单点深打、片尾一个主行动且优先二次反转剧情化承载),并在 GATE A 说明 | -| twist_variant | 任务指引式 / 双关置换式 / 身份彩蛋式 / 戏中戏式;未指定时由我据素材与故事选定,并在 GATE A 说明理由 | +| twist_variant | 任务指引式 / 双关置换式 / 身份彩蛋式 / 画面转场式 / 戏中戏式;未指定时由我据素材与故事选定,并在 GATE A 说明理由 | | story_source | 解说正文的故事来源(开源片名 / 用户素材 / AIGC 生成;开源片名优先 Blender 开源电影片库,见「素材 sourcing」) | -| voiceover | 甲方交付口播终稿路径;未交付时写明由我起草 | +| voiceover | 仅真人出镜 / 数字人口播形态填(甲方交付口播终稿路径);解说旁白形态写「不适用」——旁白由我写 | | source_mode | open_license_footage / user_provided / aigc / mixed | | assets | 素材绝对路径、来源 URL、许可证、授权确认记录 | | form | 横竖屏、时长带、画面风格、配音音色与 BGM 倾向 | @@ -104,27 +105,12 @@ GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): | gates | GATE A/B 批准人;甲方代理批准时写明批准范围 | | acceptance | 交付物、验收标准、遗留问题记录要求 | -## 阶段裁剪(对通用制作流程的细化) - -| 阶段 | 本 workflow 的做法 | -|------|--------------------| -| Stage 1 | 档位固定 **故事讲述型(narrative)** | -| Stage 2 | 甲方交付口播时跳过;未交付时故事梗概必须写明三段结构与反转手法 | -| Stage 3 | `script/script.md` 按段标注(解说 / 反转 / 植入)+ 每段时长占比 + 反转点时间码;自评过「反转幅度与接入丝滑度」四问,GATE A 随脚本交答案 | -| Stage 4 | 镜头表每镜标所属段;反转点前后各一镜写明衔接意象(意象桥) | -| Stage 6 | 现成素材轨且无 AIGC 角色时跳过角色三视图 | -| Stage 7–8 | slot 按段规划,解说段 slot 数最多;植入段 slot 必须能承载 Brief 的价值点 | -| Stage 9b | delivery_promise 中记录反转点位置与植入段占比承诺 | -| Stage 11 | 场景 B(旁白一次性 TTS + 对齐)或 D(甲方录音);BGM 优先沿用素材原生配乐轨并 ducking | -| Stage 12 | 解说段与植入段允许色调对比(冷暗 → 明亮实景/界面),但必须用钩连句 + 意象桥缝合 | -| Stage 14a | 封面优先用素材高情绪帧直用;封面主文案来自 Brief,无标题平台用核心传达 | - ## 制作护栏 1. **规格一次锁定**:横竖屏、目标时长、分辨率与帧率由 Brief 决定;不为"看起来高级"擅自放大素材。需要统一规格时先确认素材源质量与环境约束,再写入 timeline 计划与决策日志。 -2. **帧率一致性**:混排来源素材时先确认各段帧率;不一致必须在切片时用 `clip-trim --normalize WxH@FPS` 统一(或 `assemble` 归一化参数),不得不同帧率直接 concat 交付。拼接一律带 `assemble --verify-fps`(断言成片帧率)+ `--expect-durations`(逐段时长 vs 计划 ± 容差),输出后核对音画同步与段边界。 +2. **帧率一致性**:混排来源素材时先确认各段帧率;不一致必须在切片时用 `clip-trim --normalize WxH@FPS` 统一(或 `assemble` 归一化参数),不得不同帧率直接 concat 交付。拼接一律带 `assemble --verify-fps`(断言成片帧率)+ `--expect-durations`(逐段时长 vs 计划 ± 容差),输出后核对音画同步与段边界。AIGC 生成片段的帧率由生成端决定、无 CLI 参数指定(实测 24fps)——含 AIGC 段的混排,规划期就按最低公共帧率预判统一规格并写进 timeline 计划(Brief 素材清单注明了各源帧率),不要等 assemble 才发现主素材被拉低。 3. **旁白排布**:逐句 TTS 模式(解说/反转植入类默认)走 `narration-layout`——实测镜头时长累积起点、每句对齐镜头起点、防重叠守卫(min_gap)、逐句与末句越界断言、SRT 与混音一步产出;**守卫断言失败改计划(镜头时长/文案),不放宽容差硬过,更不手写排布脚本**。整段旁白模式走 `mix-audio` + `narration-align` 的实测时长 / 字级时间戳。两种模式都不用文本长度估算;每句旁白不得越过对应镜头边界(确需跨镜的桥句在 plan 里显式 `allow_spill`);连续旁白保留呼吸间隔,累计漂移可追溯到 `audio/abs_starts.json` 的每段实测时长。 -4. **字幕安全区**:`subtitles.srt` 必须来自对齐后的口播时间轴;烧录前检查目标画幅安全区,避免字幕落到画面中部或关键主体上。字幕样式由 Brief 决定,不硬编码项目个案参数。 +4. **字幕安全区**:`subtitles.srt` 必须来自对齐后的解说时间轴;烧录前检查目标画幅安全区,避免字幕落到画面中部或关键主体上。字幕样式由 Brief 决定,不硬编码项目个案参数。 5. **AIGC 原声**:AIGC 声画同出素材如含有效环境音,可作低音量音床并在口播下 ducking;无有效音轨时补静音,不用噪声填充。 6. **决策审计**:素材取舍、规格、音色、字幕样式、反转手法选择、fallback 与弃用中间产物都写入 `script/decisions.json` 或 `final-deliver.md`。 @@ -139,6 +125,6 @@ GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): 5. 反转两轴达标:铺垫段可信为纯内容视频且与产品世界落差足够(幅度)、接入句因果一句话可复述(丝滑);设二次反转时 CTA 是剧情动机的延伸。 6. 产品段占比与反转点位置落在承诺区间,且与选定手法一致。 7. 每段素材来源与授权可追溯;Blender 署名、用户素材授权确认、AIGC 标注要求均已写入 `final-deliver.md`。 -8. 口播、字幕、画面、音效与 Brief / `voiceover.md` 一致。 +8. 解说旁白 / 口播(按形态)、字幕、画面、音效与 GATE A 批准稿 / Brief / `voiceover.md` 一致。 9. `video-review` verdict=pass;未通过不得交付。 10. `final-deliver.md` 含素材来源与协议、口播终稿、各段实际时长、自检结果、弃用产物与遗留问题。 diff --git a/crews/content-producer/skills/expert-video/workflows/story-develop.md b/crews/content-producer/skills/expert-video/workflows/story-develop.md new file mode 100644 index 00000000..58145784 --- /dev/null +++ b/crews/content-producer/skills/expert-video/workflows/story-develop.md @@ -0,0 +1,39 @@ +# Workflow:Story Develop(Brief intake · 创意澄清) + +**这是 intake 类 workflow,不是 type 类 workflow。** type workflow(`narration-video` / `collage-broll` / `reversal-ad`)指导从 Brief 生产 script(含自检),是 `Brief.workflow` 的取值;本 workflow 解决"甲方还没把创意讲清楚时,怎么和他对话把 Brief 收敛出来",**不是 `Brief.workflow` 的取值**,也不与 type workflow 互斥。它在 **Stage 0(Brief intake)** 阶段触发,与任何 type workflow 正交可组合——收敛出 Brief 后,仍按通用制作流程 + 对应 type workflow 执行。 + +## 何时触发 + +- **模式 B(直接对接用户)**:用户没给 Brief,或只给了模糊想法("做个短片""帮我策划一下")。 +- **模式 A(Subagent 承制)**:main 的 Brief 缺关键字段(创意 / 核心传达不清、规格缺失),需向 Brief owner 澄清。 +- 触发判据:**甲方没给 Brief,或 Brief 的"创意"不足以直接写 `script.md`**。够用就不触发。 + +> ❗ 本 workflow 是**澄清与收敛**,不是替甲方创作。选题方向、品牌事实、卖点承诺、CTA 口径归甲方;我只把甲方脑子里的创意问清楚、整理成 Brief,不自行脑补,也不反过来指挥甲方。 + +## 怎么做(对话式,不发问卷) + +不要一次甩七个问题。先从目的与受众问起,让对话自然流动,缺什么补什么;甲方已说清的不重复问。模糊想法("做个短片")不算确认,不得据此调渲染类工具。 + +需要收敛清楚的点(对应 Brief 字段,**创意 + 规格为主**): + +1. **目的 / 核心传达**:这条视频要达成什么?观众看完该记住什么、做什么? +2. **受众 / 观看理由**:给谁看?他们为什么会看下去? +3. **平台**:发哪(抖音 / 视频号 / 小红书 / B站…)?决定画幅、时长带与合规边界。 +4. **创意 / 内容**:讲什么故事、呈现什么内容?——这是 Brief 的核心,**甲方出**;我只追问澄清,不代拟方向。 +5. **规格**:大致片长、横竖屏、画面风格倾向、有无口播(谁的声音)。 +6. **业务植入与 CTA**:植入什么、CTA 主目标与句式(口径归甲方)。 +7. **素材 / 参考 / 约束**:有无现成素材(**绝对路径**)、参考片、deadline、必含内容。 + +调性 / 风格倾向(如庄重 / 诙谐 / 紧迫)影响我的制作手法选择,问清后记 `script/decisions.json`(备选 + 置信度 + 理由)。 + +## 产出 + +- **模式 B**:代用户整理 `brief.md`(创意 + 规格 + 口播文案如有),**发用户确认定稿后才开工**。 +- **模式 A**:把澄清结果补进 Brief(或向 Brief owner 确认缺口已补齐),**不重开需求讨论**。 +- 收敛完成的标志:**`brief.md` 的创意足以直接写 `script.md`**。然后回通用制作流程**。 + +## 边界 + +- 甲方已交付口播文案 / 录音时,叙事以口播稿为准,本 workflow 只需确认口播稿到位,不另起创意。 +- 不做视频下载 / 转写 / 抽帧(那是 main 的 `viral-chaser`);甲方给了参考拆解报告才用可选工具 `reference-concepts`。 +- 不替甲方做需求决策;缺信息就问,不脑补品牌事实与授权。 diff --git a/crews/main/HEARTBEAT.md b/crews/main/HEARTBEAT.md index 49e24c62..dba622b9 100644 --- a/crews/main/HEARTBEAT.md +++ b/crews/main/HEARTBEAT.md @@ -45,68 +45,36 @@ ### 工作流程 -#### Step 1: 通过 published-track 读取待取数的已发布内容 +#### Step 1: 通过 published-track 对抖音已发布作品取数 -```bash -# 对纯 HTTP 脚本平台(douyin / kuaishou / bilibili),查询近期记录(取数时效窗口内,见 Step 2) -published-track query --platform douyin --limit 50 -``` - -列出取数时效窗口内的记录,准备在 Step 2 中逐条更新互动数据。**xhs / wx_mp / wx_channel 三个 camoufox 平台无需本步查询**——它们的 `fetch-all` 会自己查 DB 全量行并与后台首页匹配(见 Step 2 第 2/3/4 条)。 - ---- - -#### Step 2: 依次获取已发布内容的互动数据并更新到 published-track - -按平台分四种情况。第 1 条纯 HTTP 平台按 id 逐条取数;第 2/3/4 条 camoufox 平台**每平台只跑一次 `fetch-all`**——打开后台列表**首页**一次,解析页内全部作品,匹配 DB 全部行逐行写库,首页没有的行报 `NOT_ON_FIRST_PAGE` 跳过(这是设计,见「取数时效窗口」)。 - -1. **douyin / kuaishou / bilibili** —— 走 `published-track fetch-metrics`(纯 HTTP+cookie 链路:login-manager 探活 → fetch-retro-data.ts → 写库),对 Step 1 查出的每条记录按 id 逐条调: - - ```bash - published-track fetch-metrics \ - --platform --id - ``` - - 脚本封装了完整流程,返回统一 JSON 结果。**xhs / wx_mp / wx_channel 不走这个脚本**——机制不同,见下方第 2/3/4 条。 - -2. **小红书 (xhs)** —— **走 `expert-xhs` 包内 `xhs-engagement` 技能**(PATH wrapper 同名),camoufox 抓 creator 创作服务平台后台方案,与第 1 条三个纯 HTTP+cookie 平台机制完全不同,两条路独立、不耦合: +1. 执行 `published-track platform-status --platform douyin`。仅返回 `ok=true, enabled=true` 时继续;未启用直接进入 Step 2,状态读取失败记入汇总后进入 Step 2。 +2. 查询最近 30 条已发布作品(图文和视频合计 30 条,不再按天数过滤): ```bash - xhs-engagement fetch-all + published-track query --platform douyin --limit 30 ``` - > ⚠️ 不要调 `published-track fetch-metrics --platform xhs`——该子命令对 xhs 直接 exit 1 报错提示走 xhs-engagement。两条链路独立维护,避免机制错配。 -3. **微信公众号 (wx_mp)** -- **走 `expert-wx-mp` 包内 `wx-mp-engagement` 工具**(PATH wrapper 同名),camoufox 抓创作者中心方案,与第 1 条三个平台的纯 HTTP+cookie 链路完全不同,两条路独立、不耦合: +3. 按查询结果顺序,取每条作品的 `id`,依次执行: ```bash - wx-mp-engagement fetch-all + published-track fetch-metrics --platform douyin --id ``` - > ⚠️ 不要调 `published-track fetch-metrics --platform wx_mp`——该子命令对 wx_mp 直接 exit 1 报错提示走 wx-mp-engagement。两条链路独立维护,避免机制错配。 - -4. **微信视频号 (wx_channel)** —— **走 `expert-wx-channel` 包内 `wx-channel-engagement` 工具**(PATH wrapper 同名),camoufox 抓视频号助手后台方案,与 wx_mp 同源(camoufox + 解析 innerText)、与第 1 条三个纯 HTTP+cookie 平台机制完全不同,两条路独立、不耦合: - - ```bash - wx-channel-engagement fetch-all - ``` - > ⚠️ 不要调 `published-track fetch-metrics --platform wx_channel`——该子命令对 wx_channel 直接 exit 1 报错提示走 wx-channel-engagement。两条链路独立维护,避免机制错配。 - -5. **其他平台**(如有) —— 除 douyin / xhs / kuaishou / bilibili / wx_mp / wx_channel 外,其他平台暂不支持自动取数,直接跳过。 -##### 取数时效窗口 + `/note/` 和 `/video/` 链接均自动识别,无需传 `--content-id`。查询为空直接进入 Step 2。单条失败保留原始 stderr 和 exit code,继续下一条;遇到 `SESSION_EXPIRED` / exit 2,记入 `EXPIRED_PLATFORMS`,停止抖音取数并进入 Step 2。 -**发布超过 30 天的内容不再每天抓取互动数据**——数据已稳定,边际变化可忽略,反复抓只浪费配额/增加风控暴露。按平台类型: +#### Step 2: 依次对小红书、视频号、公众号取数 -- **camoufox 后台方案**(xhs / wx_mp / wx_channel):`fetch-all` **永远只打开并解析后台列表首页,不翻页**——首页本身就是天然窗口,页内有什么解析什么;首页之外的老作品报 `NOT_ON_FIRST_PAGE` 自然跳过,**这是设计不是 bug**,不要加翻页逻辑去补抓老内容,也不要按天数过滤 DB 行(少操作一次页面就少一次风控暴露)。 -- **纯 HTTP 脚本方案**(bilibili / douyin / kuaishou):Step 1 查询时加 `publish_date >= date('now', '-30 days')` 过滤,超过 30 天的行直接跳过不调 `published-track fetch-metrics`。 +按下表从上到下执行。每个平台先执行状态查询,仅返回 `ok=true, enabled=true` 时执行右侧取数命令一次;未启用直接到下一行,状态读取失败记入汇总后到下一行。 -**DNA 评估(Step 3)不受此限** +| 专家包 | 状态查询 | 取数命令 | +| --- | --- | --- | +| expert-xhs | `published-track platform-status --platform xhs` | `xhs-engagement fetch-all` | +| expert-wx-channel | `published-track platform-status --platform wx_channel` | `wx-channel-engagement fetch-all` | +| expert-wx-mp | `published-track platform-status --platform wx_mp` | `wx-mp-engagement fetch-all` | -##### 通用规则 +取数失败保留原始 stderr 和 exit code,继续下一平台;登录失效另记入 `EXPIRED_PLATFORMS`,不重登。`NOT_ON_FIRST_PAGE` 直接跳过,不补抓、不翻页。 -- **必须传 `--id `**(脚本类平台):`` 取自 Step 1 查询结果里的 `id` 字段。同一 `source_folder` 可能对应多条记录(同内容重复发布到不同帖子),按 `--id` 逐条抓取/写库才能让每次发布各自独立统计;若只传 `--source-folder`,脚本会只抓一行指标却批量写进所有同 folder 行,造成重复发布之间互相污染。 -- **SESSION_EXPIRED**:脚本返回 `ok=false, error=SESSION_EXPIRED`(exit 2)时,**跳过该平台**本轮取数,记入 `EXPIRED_PLATFORMS`,Step 5 统一汇报,由用户白天重新登录。**凌晨不唤醒用户、不扫码登录、不私拉会话**(见约束 4/5)。 -- **xhs 风控显著高于其他平台**:xhs 任何登录失效迹象 → 立刻整段跳过 xhs,不尝试任何恢复。取数走 `xhs-engagement`(creator 后台方案,复用 `xhs-browse` session),**严禁** CDP 注入 cookie / `cookies import` 造会话。 -- **⛔ 取数失败时必须原样报告脚本 stderr + exit code,禁止自行归因**:脚本的 stderr 是排查的唯一可靠依据。Agent 不得根据 DB 字段(如 `publish_url` 是否为空)脑补错误原因、不得改写/概括 stderr 成自己的话。例:`wx-mp-engagement fetch` exit 1 stderr=`error: 发表记录页未找到标题匹配的 row id=3`,就报这个原文,不要脑补成 "publish_url 无效"。错误归因错误会误导排查方向。 +目前定时任务取数仅支持已适配 expert 架构的四个平台(douyin、xhs、wx_channel、wx_mp),其他平台直接跳过。 --- @@ -120,14 +88,16 @@ content-calibrator eval --platform --check 返回 JSON:`{dnas: [{dna_id, pending, triggered}]}` - 全部 `triggered=false` → 本轮评估跳过,不消耗后续 token -- 有 `triggered=true` 的 DNA → 进入 Step 3a +- 有 `triggered=true` 的 DNA → 进入 评估 **对于douyin/wx_mp/wx_channel/xhs平台** → 走该平台专家包内的 review workflow -> 触发的 DNA 属于哪个平台,就按该平台专家包的 review workflow 执行完整复盘(聚合、平台归因、写报告、标记全在 workflow 内;**workflow 不取数**——本轮数据已在 Step 2 采集就位): +> 触发的 DNA 属于哪个平台,就按该平台专家包的 review workflow 执行完整复盘(聚合、平台归因、写报告、标记全在 workflow 内;**workflow 不取数**——本轮数据已在 Step 1–2 采集就位): > - **wx_mp** → expert-wx-mp 的 Review Workflow(`skills/expert-wx-mp/workflows/review.md`) > - **douyin** → expert-douyin 的 Review Workflow(`skills/expert-douyin/workflows/review.md`) +> - **wx_channel** → expert-wx-channel 的 Review Workflow(`skills/expert-wx-channel/workflows/review.md`) +> - **xhs** → expert-xhs 的 Review Workflow(`skills/expert-xhs/workflows/review.md`) **对于其他平台** → 尚未匹配DNA系统,直接跳过此步 @@ -160,7 +130,7 @@ content-calibrator eval --platform --check > ⚠️ 以下**取数端**登录态已失效,数据未能更新。请白天通知小贝重新登录: > - douyin(抖音) > - xhs-browse(小红书浏览端) - > - wechat-channel(微信视频号) + > - wx-channel(微信视频号) 3. DNA 表现评估摘要(如有):列出本轮评估的 DNA(平台 / dna-id / 覆盖篇数)+ 整体判定(改善 / 平稳 / 下滑)+ 关键归因;无触发 DNA 时写「无 DNA 达到评估阈值」并附各 DNA 待评估计数。 4. **DNA 优化建议待确认(如有)**:列出评估报告中的逐条建议(建议内容 + 目标维度/template 部分 + 证据篇目)。**Agent 不得自动更新 DNA**。用户白天逐条确认后,指示走对应平台专家包的 style-dna workflow 回写 DNA。 diff --git a/crews/main/TOOLS.md b/crews/main/TOOLS.md index 972cd1b8..6f0c00ba 100644 --- a/crews/main/TOOLS.md +++ b/crews/main/TOOLS.md @@ -6,9 +6,9 @@ ### 📝 视频封面/海报制作经验 -`siliconflow-img-gen` 可以很好的直接出带文字的海报,完全不必要先生成图,然后自己再编写脚本拼字。 +`awk-img-gen` 可以很好的直接出带文字的海报,完全不必要先生成图,然后自己再编写脚本拼字。 -具体见 `siliconflow-img-gen` 技能中 `视频封面/海报最佳实践`。 +具体见 `awk-img-gen` 技能中 `视频封面/海报最佳实践`。 ### 数据库查询一定走 published-track wrapper diff --git a/crews/main/skills/_shared/asr.py b/crews/main/skills/_shared/asr.py new file mode 100644 index 00000000..7a0c738c --- /dev/null +++ b/crews/main/skills/_shared/asr.py @@ -0,0 +1,73 @@ +"""asr.py — ASR 统一路由(单入口),返回结构与 volc_asr/bailian_asr 同构: + + {ok: True, text, utterances:[{start,end,text}], words:[{start,end,text}]}(时间戳秒) + {ok: False, error} + +供应商优先级(2026-09 拍板): + 1. 火山录音文件极速版 —— VOLC_ASR_APP_ID+VOLC_ASR_ACCESS_KEY(旧控制台双头) + 或 VOLC_ASR_APP_KEY(新控制台单头)在环境中即启用 + 2. 百炼业务空间 —— WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY + 3. 百炼 agent plan —— AWK_API_KEY(token-plan 端点) + +按序尝试有凭据的供应商:某家调用失败(网络/配额/接口错误)自动落到下一家; +全部失败时 error 汇总各家原因。所有凭据都未配置时 error 含 +「凭证未配置」与「VOLC_ASR」标记(talking-head-cut/cut_plan.py 以此判 exit 2)。 + +消费方(import 本模块的 asr(),不再直连 volc_asr): + - crews/main/skills/viral-chaser/scripts/transcriber.ts(python3 -c 内联段) + - crews/main/skills/talking-head-cut/scripts/cut_plan.py + - crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# 本模块与 volc_asr/bailian_asr 同目录(crews/main/skills/_shared/)。 +# 消费方 sys.path 注入的是本目录,但保险起见自注入一次,允许直接以文件路径加载。 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from bailian_asr import bailian_asr, list_bailian_endpoints # noqa: E402 +from volc_asr import load_env_file, volc_asr # noqa: E402 + +__all__ = ["asr", "load_env_file", "volc_asr", "bailian_asr"] + + +def _volc_configured() -> bool: + app_id = (os.environ.get("VOLC_ASR_APP_ID") or "").strip() + access_key = (os.environ.get("VOLC_ASR_ACCESS_KEY") or "").strip() + app_key = (os.environ.get("VOLC_ASR_APP_KEY") or "").strip() + return bool((app_id and access_key) or app_key) + + +def asr(audio_path: str) -> dict: + """按 火山 → 百炼业务空间 → 百炼 agent plan 顺序路由转写。""" + load_env_file() + + errors: list[str] = [] + tried = 0 + + if _volc_configured(): + tried += 1 + result = volc_asr(audio_path) + if result.get("ok"): + return result + errors.append(f"火山: {result.get('error', '未知错误')}") + + for endpoint in list_bailian_endpoints(): + tried += 1 + result = bailian_asr(audio_path, endpoint=endpoint) + if result.get("ok"): + return result + errors.append(f"百炼({endpoint[2]}): {result.get('error', '未知错误')}") + + if tried == 0: + return { + "ok": False, + "error": "ASR 凭证未配置:需 VOLC_ASR_APP_ID+VOLC_ASR_ACCESS_KEY 或 VOLC_ASR_APP_KEY(火山)," + "或 WORKSPACE_ID+MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY(百炼业务空间)," + "或 AWK_API_KEY(百炼 agent plan)", + } + return {"ok": False, "error": " | ".join(errors)} diff --git a/crews/main/skills/_shared/bailian_asr.py b/crews/main/skills/_shared/bailian_asr.py new file mode 100644 index 00000000..815a91a1 --- /dev/null +++ b/crews/main/skills/_shared/bailian_asr.py @@ -0,0 +1,265 @@ +"""bailian_asr.py — 阿里云百炼同步 Flash ASR(qwen-audio-3.0-asr-flash)公共调用。 + +与 volc_asr.py 返回同构: + {ok: True, text, utterances:[{start,end,text}], words:[{start,end,text}]} + {ok: False, error} +时间戳统一为秒(百炼返毫秒)。 + +协议(input-audio family,参考百炼录音文件识别 HTTP API 文档 +与 modelstudioai/cli packages/core/src/client/asr-routes.ts): + POST {base}/services/aigc/multimodal-generation/generation + headers: Authorization Bearer + Content-Type application/json + X-DashScope-SSE: disable + body: {model, input:{messages:[{role:"user", + content:[{type:"input_audio", input_audio:{data:"data:audio/;base64,..."}}]}]}, + parameters:{format}} + 响应: output.text 全文 + output.sentence.words[](整段合并为单 sentence, + 每 word 带 begin_time/end_time 毫秒与 punctuation)。 + +端点/key 双模式(resolve_bailian_endpoint): + - WORKSPACE_ID 在 → 业务空间 https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1, + key = MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY + - 否则 → agent plan https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1, + key = AWK_API_KEY + +音频以 base64 data URI 直传,编码后限 10MB;超限自动 ffmpeg 压成 +16kHz 单声道 32kbps mp3 再传(10 分钟音频约 2.4MB,稳过)。 + +utterances 合成:百炼把整段音频并成一个 sentence,本模块按 words[].punctuation +中的句末标点把词流切回多个 utterance,保持与火山返回同粒度(viral-chaser / +talking-head-cut 按 utterance 切段)。 +""" + +from __future__ import annotations + +import base64 +import os +import subprocess +import tempfile +from pathlib import Path + +DEFAULT_ASR_MODEL = "qwen-audio-3.0-asr-flash" + +WS_BASE_TEMPLATE = "https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1" +AGENT_PLAN_BASE = "https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1" +ASR_PATH = "/services/aigc/multimodal-generation/generation" + +# 编码后 base64 体积上限(百炼文档:编码后 ≤10MB,留安全余量) +MAX_B64_BYTES = 9 * 1024 * 1024 + +# 句末标点:出现即收束一个 utterance +SENTENCE_END_PUNCT = set("。!?;!?;\n") + +MIME_BY_EXT = { + "mp3": "audio/mpeg", + "wav": "audio/x-wav", + "ogg": "audio/ogg", + "opus": "audio/opus", + "m4a": "audio/mp4", + "aac": "audio/aac", + "flac": "audio/flac", + "amr": "audio/amr", +} + + +BailianEndpoint = tuple[str, str, str] # (base, api_key, mode) + + +def list_bailian_endpoints() -> list[BailianEndpoint]: + """列出所有已配置凭据的百炼端点,按优先级排序。 + + 业务空间(WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY)在前, + agent plan(AWK_API_KEY,token-plan 端点)在后。未配置的不出现。 + mode: "workspace" 或 "agent-plan"。 + """ + endpoints: list[BailianEndpoint] = [] + wsid = (os.environ.get("WORKSPACE_ID") or "").strip() + if wsid: + key = ( + os.environ.get("MODELSTUDIO_API_KEY") + or os.environ.get("DASHSCOPE_API_KEY") + or "" + ).strip() + if key: + endpoints.append((WS_BASE_TEMPLATE.format(wsid=wsid), key, "workspace")) + awk_key = (os.environ.get("AWK_API_KEY") or "").strip() + if awk_key: + endpoints.append((AGENT_PLAN_BASE, awk_key, "agent-plan")) + return endpoints + + +def resolve_bailian_endpoint() -> BailianEndpoint | None: + """取最高优先级的已配置端点;无凭据返回 None。""" + endpoints = list_bailian_endpoints() + return endpoints[0] if endpoints else None + + +def _transcode_to_mp3(audio_path: str) -> str | None: + """ffmpeg 压成 16kHz 单声道 32kbps mp3,返回临时文件路径;失败返回 None。""" + fd, tmp_path = tempfile.mkstemp(suffix=".mp3", prefix="bailian-asr-") + os.close(fd) + try: + subprocess.run( + ["ffmpeg", "-y", "-v", "error", "-i", audio_path, + "-codec:a", "libmp3lame", "-b:a", "32k", "-ac", "1", "-ar", "16000", + tmp_path], + check=True, capture_output=True, timeout=300, + ) + return tmp_path + except (OSError, subprocess.SubprocessError): + try: + os.unlink(tmp_path) + except OSError: + pass + return None + + +def _build_data_uri(audio_path: str) -> tuple[str, str, list[str]]: + """读音频构造 base64 data URI。超限自动转码。 + + 返回 (data_uri, format_hint, 待清理临时文件列表)。 + """ + cleanups: list[str] = [] + path = audio_path + raw = Path(path).read_bytes() + if len(raw) * 4 / 3 > MAX_B64_BYTES: + transcoded = _transcode_to_mp3(path) + if transcoded is None: + raise RuntimeError( + f"音频 {len(raw)} 字节超百炼 base64 上限且 ffmpeg 转码失败" + ) + cleanups.append(transcoded) + path = transcoded + raw = Path(path).read_bytes() + + ext = Path(path).suffix.lower().lstrip(".") + fmt = ext if ext in MIME_BY_EXT else "wav" + mime = MIME_BY_EXT.get(fmt, "audio/x-wav") + b64 = base64.b64encode(raw).decode("ascii") + return f"data:{mime};base64,{b64}", fmt, cleanups + + +def _split_utterances(sentence: dict, words: list[dict]) -> list[dict]: + """按 words[].punctuation 的句末标点把单 sentence 切回多个 utterance。 + + words 为原始百炼 word 条目(含 begin_time/end_time 毫秒、punctuation)。 + 无法切分(无词/无标点)时整段作一个 utterance。 + """ + utterances: list[dict] = [] + buf: list[dict] = [] + for w in words: + buf.append(w) + punct = w.get("punctuation") or "" + if any(ch in SENTENCE_END_PUNCT for ch in punct): + utterances.append({ + "start": float(buf[0].get("begin_time", 0)) / 1000.0, + "end": float(buf[-1].get("end_time", 0)) / 1000.0, + "text": "".join((x.get("text") or "") + (x.get("punctuation") or "") for x in buf), + }) + buf = [] + if buf: + utterances.append({ + "start": float(buf[0].get("begin_time", 0)) / 1000.0, + "end": float(buf[-1].get("end_time", 0)) / 1000.0, + "text": "".join((x.get("text") or "") + (x.get("punctuation") or "") for x in buf), + }) + if not utterances: + # 无词级信息时退化为整句一个 utterance + utterances.append({ + "start": float(sentence.get("begin_time", 0)) / 1000.0, + "end": float(sentence.get("end_time", 0)) / 1000.0, + "text": sentence.get("text", "") or "", + }) + return [u for u in utterances if u["text"].strip()] + + +def bailian_asr(audio_path: str, endpoint: BailianEndpoint | None = None) -> dict: + """调百炼同步 Flash ASR,返回 {ok, text, utterances, words}(与 volc_asr 同构)。 + + endpoint 显式传入 (base, api_key, mode);缺省按 resolve_bailian_endpoint() 取最高优先级。 + """ + try: + import requests + except ImportError as e: + return {"ok": False, "error": f"requests 不可用: {e}"} + + if endpoint is None: + endpoint = resolve_bailian_endpoint() + if endpoint is None: + return { + "ok": False, + "error": "百炼 ASR 凭据未配置:需 WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY" + "(业务空间)或 AWK_API_KEY(agent plan)", + } + base, api_key, mode = endpoint + model = (os.environ.get("BAILIAN_ASR_MODEL") or DEFAULT_ASR_MODEL).strip() + + try: + data_uri, fmt, cleanups = _build_data_uri(audio_path) + except Exception as e: + return {"ok": False, "error": f"读取/转码音频失败: {e}"} + + body = { + "model": model, + "input": {"messages": [{ + "role": "user", + "content": [{"type": "input_audio", "input_audio": {"data": data_uri}}], + }]}, + "parameters": {"format": fmt}, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "X-DashScope-SSE": "disable", + } + + try: + r = requests.post(f"{base}{ASR_PATH}", json=body, headers=headers, timeout=300) + except Exception as e: + return {"ok": False, "error": f"百炼 ASR 请求失败 ({mode}): {e}"} + finally: + for tmp in cleanups: + try: + os.unlink(tmp) + except OSError: + pass + + if r.status_code != 200: + return { + "ok": False, + "error": f"百炼 ASR 失败 ({mode}, HTTP {r.status_code}): {r.text[:500]}", + } + + try: + resp = r.json() + except Exception as e: + return {"ok": False, "error": f"响应解析失败: {e}; raw={r.text[:500]}"} + + output = resp.get("output") or {} + text = output.get("text") or "" + sentence = output.get("sentence") or {} + if isinstance(sentence, list): + sentence = sentence[-1] if sentence else {} + + raw_words = sentence.get("words") or [] + words = [] + for w in raw_words: + try: + words.append({ + "start": float(w.get("begin_time", 0)) / 1000.0, + "end": float(w.get("end_time", 0)) / 1000.0, + "text": (w.get("text") or "").strip(), + }) + except (TypeError, ValueError): + continue + words = [w for w in words if w["text"]] + + if not text: + text = sentence.get("text") or "" + if not text: + return { + "ok": False, + "error": f"百炼 ASR 返回空文本 ({mode}): request_id={resp.get('request_id', '')}", + } + + utterances = _split_utterances(sentence, raw_words) + return {"ok": True, "text": text, "utterances": utterances, "words": words} diff --git a/crews/main/skills/_shared/check-session.ts b/crews/main/skills/_shared/check-session.ts index e16efd53..bcbc7b5a 100644 --- a/crews/main/skills/_shared/check-session.ts +++ b/crews/main/skills/_shared/check-session.ts @@ -207,8 +207,8 @@ async function pongKuaishou(map: CookieMap): Promise<{ ok: boolean; reason?: str return { ok: false, reason: `visionProfileUserList.result=${data.data?.visionProfileUserList?.result}` }; } -async function pongXhs(map: CookieMap): Promise<{ ok: boolean; reason?: string }> { - const { xhsFetch } = await import("./relay-sign.ts"); +async function pongXhs(map: CookieMap): Promise<{ ok: boolean; reason?: string; unknown?: boolean }> { + const { xhsFetch, XhsSecurityBlockError } = await import("./relay-sign.ts"); const cookies: Record = {}; for (const [k, c] of Object.entries(map)) if (c?.value) cookies[k] = c.value; try { @@ -229,6 +229,11 @@ async function pongXhs(map: CookieMap): Promise<{ ok: boolean; reason?: string } } return { ok: false, reason: `user/me success=${r?.success} code=${r?.code}` }; } catch (e) { + // 软风控 ≠ 登录失效(速度型,cooldown 后可恢复):pong 撞上判 UNKNOWN 放行, + // 由真实请求最终裁决——同 douyin status_code=4 先例。误判 SESSION_EXPIRED 会触发无谓重登。 + if (e instanceof XhsSecurityBlockError) { + return { ok: true, unknown: true, reason: `user/me 被风控软屏蔽: ${e.message.slice(0, 100)}` }; + } const msg = e instanceof Error ? e.message : String(e); return { ok: false, reason: `user/me error: ${msg.slice(0, 120)}` }; } diff --git a/crews/main/skills/_shared/douyin-web.ts b/crews/main/skills/_shared/douyin-web.ts index ae37d117..0ec7ee46 100644 --- a/crews/main/skills/_shared/douyin-web.ts +++ b/crews/main/skills/_shared/douyin-web.ts @@ -132,3 +132,123 @@ export async function douyinWebGet( try { data = JSON.parse(text) as T } catch { /* 非 JSON,交消费方看 text */ } return { status: resp.status, ok: resp.ok, data, text } } + +// ─── 创作侧 item/list(creator 域,cookie-only,无需 a_bogus)──────────────── +// +// 借鉴 OpenCLI #2307(2026-08 实测):`item_analysis/metrics_trend` 端点已下线(全量 +// status_code 4),替代端点 `web/api/creator/item/list` 是创作侧每作品指标的家—— +// 26 字段深指标(view_count / bounce_rate_2s / completion_rate_5s / avg_view_second / +// cover_show / cover_click_rate / fan_view_proportion / subscribe_count …), +// 视频 + 图文(note) 作品都在列表里。公开侧 aweme/detail 的 play_count 恒为 0 +// (播放量仅创作者可见),view_count 是播放量的唯一来源。 +// creator 域 janus/api 只要 cookie(同 work_list 先例:publish_douyin.py 页内 +// credentials:'include' fetch,无签名),raw HTTP 带 .douyin.com cookie 即可。 + +const CREATOR_ITEM_LIST_URL = "https://creator.douyin.com/web/api/creator/item/list" +const ITEM_LIST_PAGE_SIZE = 50 +/** 游标遍历上限:500 个作品内找不到即放弃(按发布时间倒序,新作品必在前几页) */ +const ITEM_LIST_MAX_HOPS = 10 +/** 创作侧 API 间歇鉴权抖动(同 work_list status_code=8 先例),短等纯重试 */ +const ITEM_LIST_AUTH_RETRIES = 3 + +/** + * id 匹配带数值比较兜底:item/list 把 work id 序列化成 JSON number,JSON.parse + * 已按 IEEE-754 精度舍入(19 位 aweme_id 必中),字符串比较会全部 miss。 + * (借鉴 OpenCLI sameAwemeId。) + */ +export function sameAwemeId(value: unknown, target: string): boolean { + if (value == null) return false + const source = String(value) + if (source === target) return true + return /^\d+$/.test(source) && Number(source) === Number(target) +} + +/** item.metrics 值是字符串数字('1173' / '0.288638'),归一化为 number */ +function normalizeMetrics(raw: unknown): Record { + const out: Record = {} + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out + for (const [k, v] of Object.entries(raw as Record)) { + const n = typeof v === "number" ? v : parseFloat(String(v ?? "")) + if (Number.isFinite(n)) out[k] = n + } + return out +} + +export interface DouyinCreatorItem { + /** 归一化数值指标(键为平台原名:view_count / completion_rate_5s / …) */ + metrics: Record + /** 审核状态(fields=review) */ + review?: string + /** 可见性(fields=visibility) */ + visibility?: string +} + +/** + * 遍历创作侧 item/list 游标,按 aweme_id 匹配单个作品的深指标。 + * @returns 匹配作品;列表遍历尽未找到返回 null;鉴权持续失败抛错(交消费方决定降级)。 + */ +export async function douyinCreatorItem( + awemeId: string, + cookieStr: string, + ua: string = DOUYIN_UA, +): Promise { + let cursor: number | undefined + for (let hop = 0; hop < ITEM_LIST_MAX_HOPS; hop++) { + const params = new URLSearchParams({ + count: String(ITEM_LIST_PAGE_SIZE), + order_by: "1", + fields: "metrics,review,visibility", + need_cooperation: "true", + need_long_article: "true", + }) + if (cursor !== undefined) params.set("max_cursor", String(cursor)) + + let data: any = null + let ok = false + for (let attempt = 1; attempt <= ITEM_LIST_AUTH_RETRIES && !ok; attempt++) { + const resp = await fetch(`${CREATOR_ITEM_LIST_URL}?${params.toString()}`, { + headers: { + "Cookie": cookieStr, + "User-Agent": ua, + "Referer": "https://creator.douyin.com/", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9", + }, + signal: AbortSignal.timeout(30_000), + }) + const text = await resp.text() + try { data = JSON.parse(text) } catch { data = null } + // 间歇鉴权抖动:sc != 0 / 非 JSON 短等重试(同 work_list sc=8 先例,同页连发就稳) + const sc = data?.status_code + ok = resp.ok && data != null && (typeof sc !== "number" || sc === 0) + if (!ok && attempt < ITEM_LIST_AUTH_RETRIES) await new Promise(r => setTimeout(r, 2_000)) + } + if (!ok) { + const sc = data?.status_code + throw new Error(`creator item/list 失败(status_code=${sc ?? "?"}——鉴权持续抖动或 cookie 失效)`) + } + + const items = (data.items ?? []) as Array> + const hit = items.find(it => sameAwemeId(it.id, awemeId)) + if (hit) { + const review = hit.review + const visibility = hit.visibility + return { + metrics: normalizeMetrics(hit.metrics), + review: typeof review === "string" ? review : review == null ? undefined : String(review), + visibility: typeof visibility === "string" ? visibility : visibility == null ? undefined : String(visibility), + } + } + + const nextCursor = data.max_cursor + if ( + !data.has_more || + nextCursor === undefined || nextCursor === null || + (cursor !== undefined && String(nextCursor) === String(cursor)) + ) { + return null + } + cursor = Number(nextCursor) + } + return null +} diff --git a/crews/main/skills/_shared/relay-sign.ts b/crews/main/skills/_shared/relay-sign.ts index f48e520d..583a68e4 100644 --- a/crews/main/skills/_shared/relay-sign.ts +++ b/crews/main/skills/_shared/relay-sign.ts @@ -72,6 +72,25 @@ export class LoginWallError extends Error { } } +// ── xhs 软风控检测(借鉴 OpenCLI #2207)────────────────────────────────────── +// +// 速度型软风控:连读触发,页面被重定向到 website-login/error?error_code=300017/300031 +// 或渲染「安全限制/请求太频繁」等文案。软风控页也是 HTML,必须在登录墙判定**之前**区分—— +// 误归 LoginWallError 会触发无谓的重登循环(登录态本身是好的)。 +// error_code=300017/300031 是强信号;文案仅扫 HTML 响应前段(JSON 业务文本可能撞词,不扫)。 +const XHS_SECURITY_BLOCK_CODE_RE = /error_code=(?:300017|300031)/; +const XHS_SECURITY_BLOCK_TEXT_RE = /安全限制|访问链接异常|请求太频繁|访问频次异常/; + +/** xhs 风控软屏蔽(期望 JSON 时被速度型风控拦截,非登录失效)。下游应降速/冷却重试,勿触发重登。 */ +export class XhsSecurityBlockError extends Error { + readonly uri: string; + constructor(uri: string) { + super(`SECURITY_BLOCK: xhs 风控软屏蔽(期望 JSON)@ ${uri}——软风控非登录失效,cooldown 后降速重试,勿触发重登`); + this.name = "XhsSecurityBlockError"; + this.uri = uri; + } +} + // ── xhs ───────────────────────────────────────────────────────────────────── export interface XhsSignInput { @@ -159,6 +178,14 @@ export async function xhsFetch(input: XhsFetchInput): Promise { const body = await resp.text().catch(() => ""); const head = body.trimStart().slice(0, 256); const looksLikeHtml = contentType.includes("text/html") || HTML_LOGIN_WALL_RE.test(head); + // 软风控判定先于登录墙(软风控页也是 HTML,语义不同:一个要冷却,一个要重登)。 + // fetch 默认跟随重定向,软风控 redirect 的最终 URL 在 resp.url 上。 + if ( + XHS_SECURITY_BLOCK_CODE_RE.test(resp.url) || + (looksLikeHtml && (XHS_SECURITY_BLOCK_CODE_RE.test(head) || XHS_SECURITY_BLOCK_TEXT_RE.test(body.slice(0, 2000)))) + ) { + throw new XhsSecurityBlockError(`${method.toUpperCase()} ${uri}`); + } if (looksLikeHtml) { throw new LoginWallError("xhs", `${method.toUpperCase()} ${uri}`); } diff --git a/crews/main/skills/_shared/volc_asr.py b/crews/main/skills/_shared/volc_asr.py index f4e9ceb2..cfd3901e 100644 --- a/crews/main/skills/_shared/volc_asr.py +++ b/crews/main/skills/_shared/volc_asr.py @@ -1,11 +1,15 @@ -"""volc_asr.py — 火山方舟豆包语音极速版 ASR 公共调用(三处共用单源)。 +"""volc_asr.py — 火山方舟豆包语音极速版 ASR 公共调用(火山后端单源)。 -抽出前散在三处: - - crews/main/skills/talking-head-cut/scripts/cut_plan.py 的 volc_asr() - - crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py 的 fallback_asr() +⚠️ 消费方不要直接 import 本模块——统一走同目录 asr.py 路由 +(火山 → 百炼业务空间 → 百炼 agent plan,见 asr.py / bailian_asr.py)。 +本模块只作为路由的火山后端。 + +三处消费方(均已经由 asr.py): + - crews/main/skills/talking-head-cut/scripts/cut_plan.py + - crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py - crews/main/skills/viral-chaser/scripts/transcriber.ts 的 PYTHON_SCRIPT 内联段 -三方调同一火山接口(volc.bigasr.auc_turbo),凭据同池: +火山接口(volc.bigasr.auc_turbo),凭据: 旧控制台双头 VOLC_ASR_APP_ID + VOLC_ASR_ACCESS_KEY 新控制台单头 VOLC_ASR_APP_KEY diff --git a/crews/main/skills/_shared/xhs-html-note.ts b/crews/main/skills/_shared/xhs-html-note.ts index d0843fa0..504c5cb6 100644 --- a/crews/main/skills/_shared/xhs-html-note.ts +++ b/crews/main/skills/_shared/xhs-html-note.ts @@ -20,7 +20,8 @@ * xhsBrowserHeaders(ua, cookieStr) — 笔记详情页导航请求头 * extractInitialState(html) — 解 window.__INITIAL_STATE__(JSON.parse + vm 兜底) * parseXhsNoteFromHtml(html, noteId) — 合并 og:meta + __INITIAL_STATE__ → XhsHtmlNote | null - * fetchXhsNoteFromHtml(noteId, opts) — 抓取 + 解析,带重试 + captcha 检测 + * fetchXhsNoteFromHtml(noteId, opts) — 抓取 + 解析,带重试 + captcha/软风控检测 + * (软风控 → 8–18s 随机 cooldown 后单次重试,仍被挡抛 XhsSecurityBlockError) */ import vm from "node:vm" @@ -60,6 +61,18 @@ export class XhsNoteInaccessibleError extends Error { } } +/** + * 速度型软风控(借鉴 OpenCLI #2207/#2355):note 详情页连读触发,页面被重定向到 + * website-login/error?error_code=300017/300031 或渲染「安全限制/访问链接异常」文案。 + * 软风控 ≠ 登录失效——cooldown 后单次重试多数可恢复,不应触发重登流程。 + */ +export class XhsSecurityBlockError extends Error { + constructor(msg = "笔记详情页被速度型风控软屏蔽(cooldown 后仍被挡)") { + super(`SECURITY_BLOCK: ${msg}`) + this.name = "XhsSecurityBlockError" + } +} + // ── 计数解析 ───────────────────────────────────────────────────────────────── /** 解析 xhs 计数串:支持 "12345" / "1.2万" / "3.5亿" / 12345(Number)。 */ @@ -170,13 +183,26 @@ export function parseXhsNoteFromHtml(html: string, noteId: string): XhsHtmlNote let videoUrl = "" const video = note?.video if (video) { - const h264 = video?.media?.stream?.h264 ?? video?.media?.stream?.h265 ?? [] - videoUrl = h264[0]?.masterUrl ?? h264[0]?.master_url ?? "" - if (!videoUrl) { - // consumer.originVideoKey 是个 key,需拼域名——仅当无直链时作最后线索,此处不拼,留空走 og:video - const originKey = video?.consumer?.originVideoKey ?? video?.consumer?.origin_video_key - if (originKey) videoUrl = "" // 不直接用 key,交给 og:video + // Stream bucket names can change (h264/h265/av1 → EF*). Inspect every + // array bucket, retaining only usable URLs and sorting numeric quality fields. + const stream = video?.media?.stream + const quality = (value: unknown): number => { + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : 0 } + const candidates = stream && typeof stream === "object" + ? Object.values(stream).flatMap(bucket => Array.isArray(bucket) ? bucket : []) + .filter(item => item && typeof item === "object") + .map(item => ({ + url: [item.masterUrl, item.master_url].find(url => + typeof url === "string" && /^(?:https?:)?\/\//.test(url)), + height: quality(item.height), + bitrate: quality(item.avgBitrate ?? item.avg_bitrate), + })) + .filter(item => item.url) + .sort((a, b) => b.height - a.height || b.bitrate - a.bitrate) + : [] + videoUrl = candidates[0]?.url ?? "" } if (!videoUrl && og.video) videoUrl = og.video @@ -255,6 +281,21 @@ export function parseXhsNoteFromHtml(html: string, noteId: string): XhsHtmlNote const XHS_BROWSE_BASE = "https://www.xiaohongshu.com" const CAPTCHA_RE = /www\.xiaohongshu\.com\/website-login\/captcha\?redirectPath=/ + +// ── 软风控识别(借鉴 OpenCLI 75c85e5 + 3ec6eb0)───────────────────────────── +// +// error_code=300017/300031 是强信号(笔记内容不会自带该参数);「安全限制」等文案是 +// 弱信号,仅在笔记解析失败后才触达本判定(正常渲染的笔记不会走到这里),desc 撞词 +// 不会误判。fetch 默认跟随重定向,软风控页的最终 URL 带错误码。 +const SECURITY_BLOCK_CODE_RE = /error_code=(?:300017|300031)/ +const SECURITY_BLOCK_TEXT_RE = /安全限制|访问链接异常|请求太频繁|访问频次异常/ + +function isSecurityBlockPage(html: string, finalUrl: string): boolean { + if (SECURITY_BLOCK_CODE_RE.test(finalUrl) || /website-login\/error/.test(finalUrl)) return true + if (SECURITY_BLOCK_CODE_RE.test(html)) return true + return SECURITY_BLOCK_TEXT_RE.test(html) +} + const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) export interface FetchXhsNoteOpts { @@ -291,6 +332,11 @@ export async function fetchXhsNoteFromHtml( const url = `${XHS_BROWSE_BASE}/explore/${noteId}?${qs.toString()}` const headers = xhsBrowserHeaders(opts.ua ?? "", opts.cookieStr ?? "") + // 软风控单次冷却重试(借鉴 OpenCLI readXhsDetailPage):重试上限是结构化的—— + // 一个 if 标志位而非可配置次数,不可能退化成 hammer 循环。对高热风控态短间隔连打 + // 正是把它推向账号违规/封号路径的方式(OpenCLI #842/#677)。 + let securityBlockRetried = false + for (let attempt = 1; attempt <= retries; attempt++) { try { const resp = await fetch(url, { @@ -306,10 +352,25 @@ export async function fetchXhsNoteFromHtml( if (CAPTCHA_RE.test(html)) throw new XhsCaptchaError() const note = parseXhsNoteFromHtml(html, noteId) if (note) return note + if (isSecurityBlockPage(html, resp.url)) { + if (securityBlockRetried) { + throw new XhsSecurityBlockError( + `cooldown 后仍被挡(${resp.redirected ? `redirect → ${resp.url.slice(0, 80)}` : "错误页文案"})。` + + "软风控多为按请求的瞬时挑战:稍后降速重试,勿短间隔连读", + ) + } + securityBlockRetried = true + const cooldownMs = 8_000 + Math.random() * 10_000 // 8–18s 随机 + process.stderr.write( + `[xhs-html] 命中速度型软风控,cooldown ${Math.round(cooldownMs / 1000)}s 后单次重试(attempt ${attempt}/${retries})...\n`, + ) + await sleep(cooldownMs) + continue + } process.stderr.write(`[xhs-html] 第 ${attempt}/${retries} 次未解析到笔记数据,重试...\n`) await sleep(800 + Math.random() * 1200) } catch (e) { - if (e instanceof XhsCaptchaError) throw e + if (e instanceof XhsCaptchaError || e instanceof XhsSecurityBlockError) throw e process.stderr.write(`[xhs-html] 抓取异常(第 ${attempt}/${retries} 次): ${e}\n`) await sleep(800 + Math.random() * 1200) } diff --git a/crews/main/skills/expert-douyin/SKILL.md b/crews/main/skills/expert-douyin/SKILL.md index 97d30f37..8f062d60 100644 --- a/crews/main/skills/expert-douyin/SKILL.md +++ b/crews/main/skills/expert-douyin/SKILL.md @@ -29,7 +29,7 @@ metadata: - 其他文档中出现的 `douyin/dna/`、`douyin/ref/`、`douyin/outputs/`、`douyin/calibration/` 才是 Workspace 相对路径,统一从 Workspace 根目录解析。 - 只有命令清单中明确列出的 wrapper 名称可以直接作为 shell 命令调用;其余 Tool 名称仅用于定位对应说明。 -零散操作(只想发条视频、只想拆解一条参考视频、只想建个 DNA)直接用下面的工具。 +零散操作(只想发条视频或图文、只想拆解一条参考视频、只想建个 DNA)直接用下面的工具。 ## 工具清单 @@ -39,13 +39,14 @@ metadata: |------|------|------| | `douyin-style-profiler` | 生成单篇作品(视频 / 图文,`--kind`)的 DNA report,并聚合 DNA 文档与 template(视频 = Brief + 口播文案模板;图文 = 写作模板) | `douyin-style-profiler` | | `douyin-comments` | 抓取抖音视频评论(对标分析 / 标签反推用,纯 HTTP 不起浏览器) | `douyin-comments` | -| `douyin-publish` | 成片 → 抖音创作者中心发布(浏览器自动化) | `douyin-publish` | +| `douyin-note-publish` | 图组、文案与可选配乐 → 图文发布 | `douyin-note-publish` | +| `douyin-video-publish` | 成片 → 抖音创作者中心发布(浏览器自动化) | `douyin-video-publish` | 跨领域通用技能:`viral-chaser`(抖音 / B站 / 小红书视频下载拆解,DNA 采样与仿写参考的取数主力)、`smart-search`(跨平台搜索,选题调研优先走社交平台,不用通用搜索引擎)、`content-calibrator`(DNA 表现评估)、`published-track`(发布记录与指标库)、`login-manager`(抖音登录态维护)。 -素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 +素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门,仅用于 main 自做轻加工成品的自检;CP 成片质检在 CP 流程内完成)、`awk-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 -**视频全案分工硬边界**:main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营,也直接做图文内容与已有素材轻加工;视频全案的成片制作委托 `content-producer`。口播类视频的口播文案由 main 按 `narration-script` 子模块写好并随 Brief 交付(真人口播时,指导用户录音并取得录音文件),CP 不重写策略文案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 +**视频全案分工硬边界**:除非是基于已有素材轻加工,否则视频全案的制作均应委托 `content-producer`。main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营;**口播**(真人出镜 / 数字人 / 真人录音)的口播稿一律由 main 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写;真人口播时,指导用户录音并取得录音文件),CP 不重写;**旁白**(剪辑配的解说)完全由 CP 写,main 不出旁白稿。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 ## 风格与 DNA @@ -64,8 +65,8 @@ DNA 是**从一批作品样本提取并聚合出的内容生产规则集**:视 ## 平台速查与硬性红线 - **发布限频**:单抖音号每 24h ≤ 5 条;触发风控立即降级,30 分钟内不重试。 -- **串行发布**:`douyin-publish` 同一时间只能有一个发布任务在跑(浏览器 session 竞态)。 -- **AIGC 标注**:AI 生成的内容按平台规则标注,`douyin-publish fill` 已内置自主声明"内容由AI生成"。 +- **串行发布**:`douyin-video-publish` 与 `douyin-note-publish` 共用 `douyin` session 和发布锁,同一时间只能有一个发布任务在跑(浏览器 session 竞态)。 +- **AIGC 标注**:AI 生成的内容按平台规则标注,`douyin-video-publish fill` 已内置自主声明"内容由AI生成"。 - **简介引流**:视频简介可提及产品与业务,但不放明显引流信息;禁止二维码、联系方式;可引导主动搜索或看主页。 - **登录态**:浏览器操作一律走 `login-manager` 真实登录后的持久化 session,严禁 `cookies import` 造会话。 - **数据诚实**:互动数据只来自平台接口、`viral-chaser` 返回或用户提供的线索,不编造;估算值必须标注估算方法,不可得的数据写明"数据不可得"。 diff --git a/crews/main/skills/expert-douyin/tools/_shared/publish-login.md b/crews/main/skills/expert-douyin/tools/_shared/publish-login.md new file mode 100644 index 00000000..d70c89ba --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/_shared/publish-login.md @@ -0,0 +1,55 @@ +# 抖音发布:登录与异常处置 + +`douyin-note-publish` 与 `douyin-video-publish` 共用本流程、login-manager 和唯一持久化 session `douyin`。每次发布先读本说明。发布工具不负责扫码登录,也没有 `login` 子命令;`login-manager --platform douyin` 负责用户登录后的导出与验证,不会代替用户登录。 + +## 1. 打开创作者上传页并判断登录态 + +按本次形态执行一个命令: + +- 图文:`douyin-note-publish open-page` +- 视频:`douyin-video-publish open-page` + +用 `camoufox-cli --session douyin --persistent --json snapshot -i` 查看页面,必要时截图判断: + +- 用户头像 / 用户名及创作者上传界面已出现,无登录遮挡 → 继续对应工具的 `run`。 +- 跳登录页或出现要求登录的弹窗 → 进入下方登录流程,暂不上传。 +- 页面尚未加载、元素缺失或浏览器报错 → 保留错误,排查页面或环境;不能只凭无头像或 `open-page` 的 `ok=true` 判定登录成功/失效。 + +浏览器持久化 profile 才是发布登录态来源;中央 cookie 文件存在、HTTP 探活通过均不能替代创作者页面检查。视频工具没有完整的自动登录判断;图文工具可识别跳登录页,页面内登录弹窗仍由 agent 检查。 + +## 2. 首次登录或登录失效 + +1. 先读 `login-manager` 技能,停止当前发布步骤,复用同一 session 有头打开: + + ```bash + camoufox-cli --session douyin --persistent --headed --json open "https://creator.douyin.com/creator-micro/content/upload?enter_from=dou_web" + ``` + +2. 告知用户在窗口里手动完成抖音创作者中心登录,等待用户确认;不盲轮询、不自行扫码。不在 heartbeat / isolated 定时任务里启动交互登录,那里只记录并跳过。 +3. 用户确认完成后执行: + + ```bash + login-manager --platform douyin + ``` + + 该命令导出 cookie 与 UA、验证、成功后写中央存储并 close session。失败时保留窗口,按 login-manager 的错误处理,不循环导出或重登。 +4. 成功后重新执行第 1 节对应的 `open-page`,由 agent 确认创作者页面已登录,再按第 3 节选择恢复步骤。默认发布以无头方式重新启动磁盘 profile。 + +## 3. 运行中异常与恢复 + +| 现象 | 操作 | +| --- | --- | +| 上传 / 填表前明确未登录,或 exit 2 | 停止发布,走第 2 节;成功后重新检查页面与未发布内容,再继续尚未执行的步骤 | +| 已点击发布,之后登录失效 / 超时 / 取链失败 | 发布结果待核实。重登后先到管理页核实;图文可用 `douyin-note-publish get-note-link --title "完整标题"` 补取链接。不得直接重跑 `run` / `publish` | +| login-manager exit 2(导出验证未通过) | 提醒用户人工核实账号与当前页面,停止本轮自动恢复;不反复重登 | +| `session douyin 正忙` | 等已有任务完成后再操作,不起第二个 session,也不关闭别人的任务 | +| 找不到 input / 按钮,页面未跳登录 | 保留 DOM / 超时错误,检查页面;不把所有浏览器错误当成登录失效 | +| 需要实名认证 / 账号验证 | 交用户在原窗口处理,不自动绕过 | +| 显示环境异常或风控限流 | 显示问题报告并停止,不改 DISPLAY 或搭显示栈;风控停止,30 分钟内不重试 | + +## 4. 会话纪律 + +- 登录阶段的直接浏览器命令一致带 `--persistent --headed`。不要在等待用户登录时调用默认无头发布命令,以免 daemon 切模式重启窗口。 +- 图文发布需要用户监督时,所有图文子命令传 `--headed`,直接 camoufox-cli 操作也保持有头。视频发布 wrapper 默认无头:先完成 login-manager 导出关闭,再回无头发布流程。 +- 严禁 `cookies import` 或另建临时 session 导入 cookie 造会话;不在日志、作品目录或代码里记录 cookie。 +- `run` 完成后关闭 session。分步操作结束或放弃时关闭自己占用的 session;login-manager 失败保留的窗口交用户处理,不擅自关闭。 diff --git a/crews/main/skills/expert-douyin/tools/_shared/publish_browser.py b/crews/main/skills/expert-douyin/tools/_shared/publish_browser.py new file mode 100644 index 00000000..fc1f567d --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/_shared/publish_browser.py @@ -0,0 +1,49 @@ +"""Shared persistent browser transport and cross-tool fail-first lock.""" +import contextlib +import fcntl +import json +import os +from pathlib import Path +import subprocess +import tempfile + +SESSION = 'douyin' +UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload?enter_from=dou_web' +MANAGE_URL = 'https://creator.douyin.com/creator-micro/content/manage' + +@contextlib.contextmanager +def publish_lock(): + path = Path(tempfile.gettempdir()) / f'xiaobei-douyin-publication-{os.getuid()}.lock' + with path.open('a') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise RuntimeError('session douyin 正忙,请等待当前操作完成后再试') + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + +class Browser: + def __init__(self, headed=False): + self.headed = headed + + def command(self, *args, timeout=60): + cmd = [os.environ.get('CAMOUFOX_CLI', 'camoufox-cli'), '--session', SESSION, + '--persistent', '--json'] + if self.headed: + cmd.append('--headed') + result = subprocess.run(cmd + list(args), capture_output=True, text=True, timeout=timeout) + if result.returncode: + raise RuntimeError(result.stderr.strip() or result.stdout.strip() or 'camoufox-cli failed') + envelope = json.loads(result.stdout) + if envelope.get('ok') is False or envelope.get('success') is False: + raise RuntimeError(str(envelope.get('error', envelope))) + data = envelope.get('data') + return data.get('result') if isinstance(data, dict) and 'result' in data else data + + def eval(self, js): + return self.command('eval', js) + + def close(self): + self.command('close', timeout=15) diff --git a/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md index c66f43fe..ad053188 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md +++ b/crews/main/skills/expert-douyin/tools/douyin-comments/SKILL.md @@ -9,9 +9,11 @@ description: 抓取抖音视频的评论列表(纯 HTTP + cookie + 签名, 抓取指定抖音视频的评论,供对标分析、起号标签反推、评论动机解读使用。 -**输入**:视频 `aweme_id` 或视频链接(支持 `v.douyin.com` 短链,自动展开)。 +**输入**:作品 `aweme_id` 或视频 / 图文链接(支持 `v.douyin.com` 短链,自动展开)。 **输出**:JSON(stdout,含评论全文、点赞数、回复数、用户昵称、IP 属地、日期);`--output` 时额外落一份按点赞降序的 markdown 摘要。 +评论接口不可用时停止本轮抓取;空响应、非零状态码不等于登录失效,也不等于作品没有评论。只有成功响应中明确给出空评论列表、无后续页且总数为 0 才可按无评论处理。 + 登录态复用中央存储导出的 douyin cookie + UA(与 `douyin` 持久化 session 同一登录态),纯 HTTP 请求,不启动浏览器。 ## 使用方式 @@ -53,6 +55,7 @@ douyin-comments fetch \ ## 必做约束 - 只读抓取,不发表、不点赞、不回复任何评论。 +- 脚本翻页间隔 1–3 秒,评论去重;游标不前进或没有新增评论时停止,不重复请求同页。 - 单次任务批量抓多条视频评论时逐条串行调用,控制总条数(每条 ≤ `--limit`),避免批量请求触风控。 - 评论文本是用户原话,分析时按动机归类(喜欢内容价值 / 喜欢人物状态 / 喜欢形式设定 / 提出具体问题 / 非恶意吐槽),不要把评论数直接当内容质量。 @@ -60,6 +63,9 @@ douyin-comments fetch \ | code | 含义 | 调用方动作 | |------|------|-----------| -| `0` | 抓取成功(`truncated=true` 表示达到 limit 或分页中断,未抓全) | 继续分析 | +| `0` | 抓取成功(`truncated=true` 表示达到 limit,未抓全) | 继续分析 | | `1` | 参数错 / 网络错 / 签名不可用(stderr 有原因) | 排查后重试;签名不可用交 IT engineer 配凭证 | -| `2` | `SESSION_EXPIRED`——cookie 缺失或失效 | 走 `login-manager --platform douyin` 有头重登后重试 | +| `2` | `SESSION_EXPIRED`——本地 cookie 缺失 | 走 `login-manager --platform douyin` 有头重登后重试 | +| `3` | 评论接口不可用、请求中断或分页停滞;stdout 保留已抓取的部分评论和具体错误 | 停止本轮抖音评论采样,标记数据不可得或样本不完整;不重登、不立即重试 | + +exit 3 的部分结果不能当作完整评论分布。对标、起号或复盘继续使用已有作品证据,缺失的评论维度明确标注;如用户已有评论导出或截图,可据其补充。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts b/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts index 0c0db0b1..15832013 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts +++ b/crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts @@ -17,12 +17,14 @@ * Exit codes: * 0 成功 * 1 一般错误(参数 / 网络 / 签名不可用) - * 2 SESSION_EXPIRED — cookie 缺失或失效,调用方走 login-manager 重登 + * 2 SESSION_EXPIRED — 本地 cookie 缺失,调用方走 login-manager + * 3 评论接口不可用 / 分页停滞,输出部分数据,不触发重登 */ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs" import { dirname, join } from "path" import { homedir } from "os" +import { pathToFileURL } from "url" // ─── Types ──────────────────────────────────────────────────────────────── @@ -145,7 +147,7 @@ function cookieHeader(dict: Record): string { // ─── aweme_id 解析 ──────────────────────────────────────────────────────── function extractAwemeId(url: string): string | null { - const match = url.match(/\/video\/(\d+)/) + const match = url.match(/\/(?:video|note)\/(\d+)/) return match ? match[1] : null } @@ -206,54 +208,71 @@ async function fetchComments(awemeId: string, limit: number): Promise douyinWebGet( + COMMENT_URI, { aweme_id: awemeId, cursor, count, item_type: 0 }, cookieStr, ua, + )) +} + +type CommentPage = { ok: boolean; status: number; data: CommentListResponse | null; text?: string } + +/** One bounded read per page. Endpoint failure is not proof of expired login. */ +export async function collectComments( + awemeId: string, + limit: number, + request: (cursor: number, count: number) => Promise, + pause: (ms: number) => Promise = ms => new Promise(resolve => setTimeout(resolve, ms)), +): Promise { const comments: FlatComment[] = [] + const seen = new Set() let cursor = 0 let total = 0 - let truncated = false - + const interrupted = (error: string): FetchResult => ({ + ok: false, awemeId, total, fetched: comments.length, truncated: true, comments, error, + }) while (comments.length < limit) { - const remaining = limit - comments.length - const count = Math.min(PAGE_SIZE, Math.max(remaining, 1)) - let resp: Awaited>> | null = null - - // status_code=8 为间歇鉴权抖动(同 douyin-publish work_list 的已知行为),重试 2 次 - for (let attempt = 0; attempt < 3; attempt++) { - resp = await douyinWebGet( - COMMENT_URI, - { aweme_id: awemeId, cursor, count, item_type: 0 }, - cookieStr, - ua, - ) - if (resp.data?.status_code !== 8) break - await new Promise(r => setTimeout(r, 1000 * (attempt + 1))) + let resp: CommentPage + try { + resp = await request(cursor, Math.min(PAGE_SIZE, limit - comments.length)) + } catch (e) { + return interrupted(`COMMENT_REQUEST_FAILED: ${(e as Error).message}`) } - - const data = resp?.data - if (!resp?.ok || !data || data.status_code !== 0) { - const code = data?.status_code ?? resp?.status ?? "unknown" - // 登录态失效常见表现为非 0 状态码 + 空评论;首屏即失败按 SESSION_EXPIRED 交重登 - if (comments.length === 0) { - return { ok: false, awemeId, total: 0, fetched: 0, truncated: false, comments: [], error: `SESSION_EXPIRED(comment list status_code=${code})` } + const data = resp.data + if (!resp.ok || !data || data.status_code !== 0 || !Array.isArray(data.comments)) { + return interrupted(`COMMENT_API_UNAVAILABLE: HTTP=${resp.status}, status_code=${data?.status_code ?? "missing"}`) + } + if (typeof data.total === "number" && data.total >= 0) total = data.total + const before = comments.length + for (const raw of data.comments) { + if (!raw || typeof raw !== "object") continue + const item = flatten(raw) + if (!item.text) continue + const key = item.cid || JSON.stringify([item.userName, item.text, item.createTime]) + if (!seen.has(key)) { + seen.add(key) + comments.push(item) } - truncated = true - break } - - total = data.total || total - const page = (data.comments || []).map(flatten).filter(c => c.text) - comments.push(...page) - const hasMore = Boolean(data.has_more) - if (!hasMore || page.length === 0) break - cursor = typeof data.cursor === "number" ? data.cursor : cursor + count - } - - if (comments.length > limit) { - comments.length = limit - truncated = true + if (!hasMore) { + // Explicit empty list + no-more + total=0 is valid; empty positive totals are ambiguous. + if (!comments.length && (data.total !== 0 || ![0, false].includes(data.has_more as number | boolean))) { + return interrupted("COMMENT_API_UNAVAILABLE: empty comments without explicit zero total and end-of-list") + } + const truncated = comments.length > limit + return { ok: true, awemeId, total, fetched: Math.min(comments.length, limit), + truncated, comments: comments.slice(0, limit) } + } + if (comments.length >= limit) { + return { ok: true, awemeId, total, fetched: limit, truncated: true, comments: comments.slice(0, limit) } + } + if (comments.length === before || typeof data.cursor !== "number" || + !Number.isFinite(data.cursor) || data.cursor <= cursor) { + return interrupted("COMMENT_PAGINATION_STALLED: no new comments or cursor did not advance") + } + cursor = data.cursor + await pause(1000 + Math.floor(Math.random() * 2000)) } - - return { ok: true, awemeId, total, fetched: comments.length, truncated, comments } + return { ok: true, awemeId, total, fetched: comments.length, truncated: false, comments } } // ─── Markdown 摘要 ──────────────────────────────────────────────────────── @@ -265,7 +284,7 @@ function markdownDigest(result: FetchResult): string { "", `- 抓取时间:${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC`, `- 评论总数(平台口径):${result.total}`, - `- 本次抓取:${result.fetched} 条${result.truncated ? "(已达 --limit,未抓全)" : ""}`, + `- 本次抓取:${result.fetched} 条${result.truncated ? "(未抓全)" : ""}`, "- 排序:点赞降序", "", "| # | 点赞 | 回复 | 评论 | 用户 | IP | 日期 |", @@ -312,12 +331,9 @@ async function main(): Promise { const result = await fetchComments(awemeId, limit) if (!result.ok) { - if (result.error?.startsWith("SESSION_EXPIRED")) { - process.stderr.write(JSON.stringify({ ok: false, error: "SESSION_EXPIRED", platform: "douyin" }) + "\n") - process.exit(2) - } - process.stderr.write(`❌ ${result.error}\n`) - process.exit(1) + process.stdout.write(JSON.stringify(result, null, 2) + "\n") + process.stderr.write(`❌ ${result.error};停止本轮评论抓取,不据此重登。\n`) + process.exit(3) } console.error(` ✓ 抓到 ${result.fetched}/${result.total} 条评论`) @@ -331,7 +347,7 @@ async function main(): Promise { process.stdout.write(JSON.stringify(result, null, 2) + "\n") } -main().catch(e => { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(e => { process.stderr.write(`❌ ${e}\n`) process.exit(1) }) diff --git a/crews/main/skills/expert-douyin/tools/douyin-note-publish/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-note-publish/SKILL.md new file mode 100644 index 00000000..4dd090a0 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-note-publish/SKILL.md @@ -0,0 +1,59 @@ +--- +name: douyin-note-publish +description: 用持久化浏览器发布抖音图文,支持多图、描述话题、上传后读取推荐音乐并选曲与图文链接回收。 +--- + +# 图文发布工具 + +本工具位于 expert-douyin 的 tools 层,由 content-production / editing 调用,不独立注册技能。视频使用 `douyin-video-publish`。 + +## 输入与输出 + +- 图片:按传参顺序上传 1–35 张,jpg/jpeg/png/webp/bmp/tif,单张非空且 ≤50MB,建议 3:4 或 4:3。 +- 标题:1–20 字;描述(含内联 `#话题`):≤1000 字。 +- 上传前只确定配乐风格或原声意图,不指定歌名。上传完成后读取页面实际推荐候选,根据内容选择合适配乐。 +- 默认声明 AI 生成;纯实拍且无需 AI 声明时显式传 `--declaration none`。 +- 成功返回 `url=https://www.douyin.com/note/`、`mid`、`content_id`。 + +## 发布前置与登录异常(必做) + +先读并执行[共用登录流程](../_shared/publish-login.md),图文与视频使用相同登录态和恢复步骤: + +1. `douyin-note-publish open-page`,用 snapshot / 截图检查头像、用户名及创作者页面。 +2. 未登录或运行中 exit 2:有头打开创作者中心,等用户完成登录,再执行 `login-manager --platform douyin` 导出验证;该命令本身不代用户登录。 +3. 验证成功后重新 `open-page` 并检查页面。若此前已点击发布,先核实管理页 / 补取链接,不重跑发布。 + +同 session 的有头参数保持一致;登录期间不调用默认无头发布命令。详细命令、登录验证失败、限流及异常恢复见共用流程。 + +## 发布 + +页面确认已登录后,按顺序执行;候选必须在上传完成后读取: + +```bash +douyin-note-publish upload --images /path/cover.png /path/page2.png +douyin-note-publish music-list +# 阅读返回的 name / author / duration,根据作品内容选择候选,复制其 choice +douyin-note-publish music-select --choice "上一步实际返回的choice" +douyin-note-publish fill --title "图文标题" --caption "描述 #话题" +douyin-note-publish publish +douyin-note-publish get-note-link --title "图文标题" +``` + +`upload` 等全部图片上传完成后才返回。`music-list` 打开音乐面板并读取当前候选,不预设歌名或搜索不存在的歌曲。需要其他分类时通过浏览器页面切换推荐 / 热门榜 / 纯音乐等实际可见分类,再运行 `music-list`。每次读取会更新候选编号;页面刷新、候选变化后必须重新读取。 + +`music-select` 仅接受当前页面返回的 `choice`,校验歌曲名、作者、时长及对应卡片,激活后只点击该卡片内的“使用”,确认面板关闭且表单“修改音乐”旁显示目标歌曲。验证失败停止,不能继续发布。`publish` 再次核对已选配乐。 + +只有明确决定使用原声时,跳过两个音乐命令,使用 `publish --original-sound`;也可调用 `run --original-sound --images ... --title ... --caption ...` 完成原声发布。不要为了省略选曲默认使用原声。`run` 不支持预先指定音乐。 + +## 分步与恢复 + +中间态由脚本保存在同一浏览器 session / 页面;不要插入其他抖音任务。刷新后重新读取候选、选择并验证。`publish` 仅表示已跳转管理页,必须接 `get-note-link`,拿到链接才记录成功。取链最多刷新并搜索 4 次,每次搜索后先等 5 秒,再轮询候选最多 30 秒,超时后间隔 3 秒重新搜索。在管理页标题与正文合并区域定位唯一标题前缀候选,判定与点击在同一次浏览器操作中完成;列表刷新暂时为空时继续等待。进入图文编辑页核验完整标题一致后读取 URL,不保存修改。多个候选或编辑页标题不符时停止;不通过列表首条或置顶视频猜链接,不自动重新发布。 + +`get-note-link` 完成后自动关闭 session;分步中途放弃时手动 `camoufox-cli --session douyin --persistent --json close`(有头时加 `--headed`)。 + +- exit 0:当前步骤成功;完整发布以 `run` / `get-note-link` 返回 note URL 为准。 +- exit 1:参数、DOM 或浏览器错误;若已点击发布,先核实管理页,不能直接重跑 run。 +- exit 2:明确登录失效,交 login-manager;heartbeat 中只记录并跳过。 +- exit 3:发布结果或链接待核实;查看 `/tmp/dy-note-debug-*.json`,仅补取链接,不自动重发。同标题有多个候选时人工核实。 + +图文和视频共享任务锁与 session,忙时 fail-first,等当前任务结束再操作。单账号视频与图文合计每 24h ≤5 条,触发风控 30 分钟内不重试。审核中也可记录已确认的 note 链接;发布后以 `published-track record --platform douyin --content-type post` 入库,heartbeat 统一取数。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-note-publish/douyin-note-publish.sh b/crews/main/skills/expert-douyin/tools/douyin-note-publish/douyin-note-publish.sh new file mode 100755 index 00000000..97fbd0a5 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-note-publish/douyin-note-publish.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" +exec python3 "$SCRIPT_DIR/scripts/publish_douyin_note.py" "$@" diff --git a/crews/main/skills/expert-douyin/tools/douyin-note-publish/scripts/publish_douyin_note.py b/crews/main/skills/expert-douyin/tools/douyin-note-publish/scripts/publish_douyin_note.py new file mode 100644 index 00000000..5e880361 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-note-publish/scripts/publish_douyin_note.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Publish image notes through the persistent douyin browser session.""" +import argparse +import json +from pathlib import Path +import re +import sys +import tempfile +import time +from urllib.parse import urlparse, parse_qs + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / '_shared')) +from publish_browser import Browser, SESSION, UPLOAD_URL, MANAGE_URL, publish_lock + +VISIBLE = 'e.getClientRects().length > 0' + +class LoginRequired(RuntimeError): + pass + + +class WaitTimeout(RuntimeError): + pass + + +def check_login(b): + url = b.eval('window.location.href') or '' + if urlparse(url).path.rstrip('/') in ('/login', '/creator-micro/login'): + raise LoginRequired('SESSION_EXPIRED: 创作者中心跳转登录页') + + + +def wait_for(b, action, message, timeout=60): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + check_login(b) + value = action() + if value: + return value + time.sleep(1) + raise WaitTimeout(message) + + +def click_text(b, text, selector='button,div,span,a,li,label'): + return b.eval(f'''(() => {{const nodes=[...document.querySelectorAll({json.dumps(selector)})] + .filter(e=>{VISIBLE} && (e.children.length===0 || e.tagName==='BUTTON') && e.textContent.trim()==={json.dumps(text)}); + if(nodes.length!==1) return false; (nodes[0].closest('button')||nodes[0]).click(); return true;}})()''') + + +def validate(images=None, title='', caption=''): + if not title.strip() or len(title) > 20: + raise ValueError('图文标题必须为 1–20 字') + if len(caption) > 1000: + raise ValueError('图文描述不能超过 1000 字') + if images is not None: + if not 1 <= len(images) <= 35: + raise ValueError('图文需要 1–35 张图片') + for name in images: + path = Path(name) + if not path.is_file() or path.suffix.lower() not in {'.jpg','.jpeg','.png','.webp','.bmp','.tif'}: + raise ValueError(f'图片不存在或格式不支持: {name}') + if not 0 < path.stat().st_size <= 50 * 1024 * 1024: + raise ValueError(f'图片必须非空且不超过 50MB: {name}') + + +def upload(b, images): + b.command('open', UPLOAD_URL) + check_login(b) + wait_for(b, lambda: click_text(b, '发布图文'), '找不到发布图文标签') + b.command('upload', 'input[type=file][accept*="image"]', + *[str(Path(p).resolve()) for p in images], timeout=300) + wait_for(b, lambda: b.eval('!!document.querySelector(\'input[placeholder="添加作品标题"]\')'), + '图片上传超时,标题表单未出现', timeout=300) + wait_for(b, lambda: b.eval(f"document.body.innerText.includes('已添加{len(images)}张图片') && !document.body.innerText.includes('取消上传')"), + '图片尚未全部上传成功,不能选择推荐音乐', timeout=300) + + +# Candidate tokens and DOM references live in the current page, so reloading invalidates them. +CARD_DATA = """const describe = e => ({ + name:e.querySelector('[class*=song-name]')?.textContent.trim(), + author:e.querySelector('.song-author')?.textContent.trim(), + duration:e.querySelector('.song-duration')?.textContent.trim() +});""" + + +def list_music(b): + check_login(b) + opened = b.eval('''(() => { + if([...document.querySelectorAll('[class*=card-wrapper]')].some(e=>e.getClientRects().length)) return true; + const actions=[...document.querySelectorAll('span[class*=action]')].filter(e=> + e.getClientRects().length && ['选择音乐','修改音乐'].includes(e.textContent.trim())); + if(actions.length!==1) return false; actions[0].click(); return true; + })()''') + if not opened: + raise RuntimeError('找不到唯一音乐入口;先完成图片上传') + js = '''(() => {''' + CARD_DATA + ''' + const cards=[...document.querySelectorAll('[class*=card-wrapper]')].filter(e=>e.getClientRects().length); + if(!cards.length) return null; + window.__noteMusicCandidates = new Map(); + return cards.map(e=>{ + const data=describe(e), choice=crypto.randomUUID(); + window.__noteMusicCandidates.set(choice,{element:e,data}); + return {choice,...data,usage:e.querySelector('[class*=user-count]')?.textContent.trim()}; + }); + })()''' + return wait_for(b, lambda: b.eval(js), '未获取到音乐候选;检查页面,不猜测歌名') + + +def select_music(b, choice): + check_login(b) + key = json.dumps(choice) + selected = b.eval('''(() => {''' + CARD_DATA + f''' + const entry=window.__noteMusicCandidates?.get({key}); + if(!entry || !entry.element.isConnected || !entry.element.getClientRects().length || + JSON.stringify(describe(entry.element))!==JSON.stringify(entry.data)) return null; + window.__noteSelectedMusic=null; + if(!entry.element.closest('[class*=card-container-act]')) entry.element.click(); + return entry.data; + }})()''') + if not selected: + raise RuntimeError('候选已失效或不存在;重新 music-list 后选择') + use = '''(() => {''' + CARD_DATA + f''' + const entry=window.__noteMusicCandidates?.get({key}); + if(!entry || !entry.element.isConnected || JSON.stringify(describe(entry.element))!==JSON.stringify(entry.data)) return false; + const active=entry.element.closest('[class*=card-container-act]'); + if(!active || !active.getClientRects().length) return false; + const buttons=[...active.querySelectorAll('button')].filter(e=>!e.disabled && e.textContent.trim()==='使用'); + if(buttons.length!==1) return false; buttons[0].click(); return true; + }})()''' + wait_for(b, lambda: b.eval(use), '目标歌曲激活卡片内未找到唯一使用按钮') + wait_for(b, lambda: verify_music(b, selected['name']), '配乐断言失败,禁止发布') + b.eval(f'window.__noteSelectedMusic={json.dumps(selected,ensure_ascii=False)}') + return selected + + +def verify_music(b, name): + return b.eval(f'''(() => {{ + if([...document.querySelectorAll('[class*=card-wrapper]')].some(e=>e.getClientRects().length)) return false; + const labels=[...document.querySelectorAll('div,span')].filter(e=> + e.getClientRects().length && !e.children.length && e.textContent.trim()==={json.dumps(name)}); + return labels.some(e=>{{let p=e; for(let i=0;i<5 && p && p!==document.body;i++,p=p.parentElement) + {{if(p.innerText.includes('修改音乐')) return true;}} return false;}}); + }})()''') + + +def fill_input(b, selector, value): + # camoufox-cli fill accepts snapshot refs only, not CSS selectors. + result = b.eval(f'''(() => {{ + const inputs=[...document.querySelectorAll({json.dumps(selector)})].filter(e=>{VISIBLE}); + if(inputs.length!==1) throw new Error('输入框缺失或不唯一'); + const e=inputs[0]; + if(!(e instanceof HTMLInputElement) || e.disabled || e.readOnly) + throw new Error('输入框不可编辑'); + e.focus(); + const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; + setter.call(e,{json.dumps(value)}); + e.dispatchEvent(new Event('input',{{bubbles:true}})); + e.dispatchEvent(new Event('change',{{bubbles:true}})); + return e.value==={json.dumps(value)}; + }})()''') + if not result: + raise RuntimeError('输入框读回不一致') + # Read in a separate browser turn, after controlled-component updates. + if not b.eval(f'''(() => {{ + const inputs=[...document.querySelectorAll({json.dumps(selector)})].filter(e=>{VISIBLE}); + if(inputs.length!==1 || inputs[0].value!=={json.dumps(value)}) return false; + inputs[0].focus(); return true; + }})()'''): + raise RuntimeError('输入框更新后读回不一致') + + +def fill(b, title, caption, declaration='ai'): + check_login(b) + fill_input(b, 'input[placeholder="添加作品标题"]', title) + js = f'''(() => {{const editors=[...document.querySelectorAll('div[contenteditable=true]')].filter(e=>{VISIBLE}); + if(editors.length!==1) return false; const e=editors[0]; e.focus(); + const r=document.createRange(); r.selectNodeContents(e); const s=window.getSelection(); s.removeAllRanges(); s.addRange(r); + document.execCommand('insertText',false,{json.dumps(caption)}); + return e.innerText.trim()==={json.dumps(caption.strip())};}})()''' + if not b.eval(js): + raise RuntimeError('描述框缺失、不唯一或读回不一致') + if declaration == 'ai': + opened = click_text(b, '请选择自主声明') or click_text(b, '自主声明') + if opened: + wait_for(b, lambda: click_text(b, '内容由AI生成'), 'AI 声明选项未找到') + if not click_text(b, '确定', 'button'): + raise RuntimeError('AI 声明确认失败') + + +def note_link_candidate(b, title, *, click=False): + # 管理页的标题区域实际展示标题+正文;搜索结果也可能包含无关作品。 + # 前缀仅用于定位候选,最终必须在图文编辑页完整校验标题。 + return b.eval(f'''(() => {{ + const title={json.dumps(title)}; + const nodes=[...document.querySelectorAll('[class*="info-title-text-"]')] + .filter(e=>{VISIBLE} && (e.textContent||'').trim().startsWith(title)); + const actions=new Set(); + for(const t of nodes) {{ + const card=t.closest('[class*="info-title-operation-"]'); + if(!card) continue; + const edits=[...card.querySelectorAll('button,a,span,div')].filter(e=> + {VISIBLE} && !e.children.length && e.textContent.trim()==='编辑作品'); + if(edits.length===1) actions.add(edits[0]); + }} + const result={{titles:nodes.length,actions:actions.size}}; + if(nodes.length===1 && actions.size===1) {{ + if({json.dumps(click)}) [...actions][0].click(); + result.status='unique'; + }} else result.status=nodes.length>1 || actions.size>1 ? 'ambiguous' : 'missing'; + return result; + }})()''') + + +def wait_note_edit(b, title, timeout=30): + diagnostic = {} + def locate_and_click(): + nonlocal diagnostic + # DOM 判定和点击在同一次 JS 执行内完成,列表刷新不能插入两者之间。 + diagnostic = note_link_candidate(b, title, click=True) + if diagnostic['status'] == 'ambiguous': + raise RuntimeError(f'多个标题前缀候选,需人工核实,不能重发: {diagnostic}') + return diagnostic['status'] == 'unique' + try: + wait_for(b, locate_and_click, '作品列表尚未出现目标', timeout=timeout) + except WaitTimeout as exc: + raise WaitTimeout(f'作品列表等待超时: {diagnostic}') from exc + + +def get_note_link(b, title): + if not title.strip(): + raise ValueError('取链需要完整非空标题') + b.command('open', MANAGE_URL) + attempts = 4 + for attempt in range(attempts): + b.command('reload') + check_login(b) + wait_for(b, lambda: b.eval('!!document.querySelector(\'input[placeholder*="搜索作品"]\')'), '管理页搜索框未出现') + fill_input(b, 'input[placeholder*="搜索作品"]', title) + b.command('press', 'Enter') + # 等待搜索后的列表替换,避免立即点击尚未刷新的旧列表。 + time.sleep(5) + try: + wait_note_edit(b, title) + except WaitTimeout as exc: + if attempt == attempts - 1: + raise RuntimeError(f'重新搜索{attempts}次仍未找到唯一作品,不能重发: {exc}') from exc + print(f'[retry] 取链搜索 {attempt + 1}/{attempts}: {exc}; 3秒后重新搜索', file=sys.stderr) + time.sleep(3) + continue + break + def read_id(): + url = b.eval('window.location.href') or '' + parsed = urlparse(url) + mid = parse_qs(parsed.query).get('mid', [''])[0] + return mid if parsed.hostname == 'creator.douyin.com' and parsed.path.endswith('/content/post/image') and re.fullmatch(r'\d{19}', mid) else None + mid = wait_for(b, read_id, '未捕获图文编辑页 mid') + def read_title(): + return b.eval(f'''(() => {{ + const inputs=[...document.querySelectorAll('input[placeholder="添加作品标题"]')].filter(e=>{VISIBLE}); + return inputs.length===1 && inputs[0].value ? {{title:inputs[0].value}} : null; + }})()''') + actual = wait_for(b, read_title, '图文编辑页标题未加载,链接待核实') + if actual['title'] != title: + raise RuntimeError(f'图文编辑页标题不一致,链接待核实: {actual["title"]!r}') + return {'ok': True, 'session': SESSION, 'content_id': mid, 'mid': mid, + 'url': f'https://www.douyin.com/note/{mid}'} + + +def publish(b, original_sound=False): + check_login(b) + if not original_sound: + selected = b.eval('window.__noteSelectedMusic || null') + if not selected or not verify_music(b, selected['name']): + raise RuntimeError('尚未确认配乐;先 music-list / music-select,或明确选择 --original-sound') + if not click_text(b, '发布', 'button'): + raise RuntimeError('未找到唯一可见发布按钮') + wait_for(b, lambda: '/content/manage' in (b.eval('window.location.href') or ''), '发布后未跳转管理页,请核实后再操作') + + +def build_parser(): + p = argparse.ArgumentParser(prog='douyin-note-publish') + sub = p.add_subparsers(dest='cmd', required=True) + for cmd in ('open-page','upload','music-list','music-select','fill','publish','get-note-link','run'): + s = sub.add_parser(cmd) + s.add_argument('--headed', action='store_true') + if cmd in ('run','upload'): + s.add_argument('--images', nargs='+', required=True) + if cmd in ('run','fill','get-note-link'): + s.add_argument('--title', required=True) + if cmd in ('run','fill'): + s.add_argument('--caption', default='') + s.add_argument('--declaration', choices=('ai','none'), default='ai') + if cmd == 'music-select': + s.add_argument('--choice', required=True) + if cmd in ('publish','run'): + s.add_argument('--original-sound', action='store_true', required=cmd == 'run', + help='明确使用原声;有配乐时必须使用分步流程') + return p + + +def main(argv=None): + a = build_parser().parse_args(argv) + b = Browser(a.headed) + publish_attempted = False + try: + if a.cmd in ('run','fill','upload'): + validate(getattr(a,'images',None), getattr(a,'title','上传'), getattr(a,'caption','')) + with publish_lock(): + try: + if a.cmd == 'open-page': + b.command('open', UPLOAD_URL) + result = {'ok':True,'session':SESSION,'url':b.eval('window.location.href'), + 'hint':'用页面元素判定登录态,未登录交 login-manager'} + else: + result = {'ok':True,'session':SESSION} + if a.cmd in ('upload','run'): + upload(b,a.images) + if a.cmd == 'music-list': + result['candidates'] = list_music(b) + if a.cmd == 'music-select': + result['music'] = select_music(b,a.choice) + if a.cmd in ('fill','run'): + fill(b,a.title,a.caption,a.declaration) + if a.cmd in ('publish','run'): + publish_attempted = True + publish(b,a.original_sound) + if a.cmd in ('get-note-link','run'): + try: + result = get_note_link(b,a.title) + except LoginRequired: + raise + except Exception as exc: + with tempfile.NamedTemporaryFile(mode='w',prefix='dy-note-debug-',suffix='.json',delete=False) as f: + json.dump({'error':str(exc),'title':a.title},f,ensure_ascii=False) + print(json.dumps({'ok':False,'error':'LINK_UNCONFIRMED','debug':f.name,'hint':'人工核实,禁止自动重发'},ensure_ascii=False)) + return 3 + print(json.dumps(result,ensure_ascii=False)) + finally: + if a.cmd in ('run','get-note-link'): + try: + b.close() + except Exception as exc: + print(f'close: {exc}',file=sys.stderr) + return 0 + except LoginRequired as exc: + print(json.dumps({'ok':False, 'error':'SESSION_EXPIRED', 'platform':SESSION, + 'publish_attempted':publish_attempted, + 'hint':'按共用登录流程恢复;若已点击发布,先核实管理页,不重发'},ensure_ascii=False)) + print(f'error: {exc}',file=sys.stderr) + return 2 + except Exception as exc: + print(f'error: {exc}',file=sys.stderr) + return 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md index 9d496675..56105520 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md @@ -22,7 +22,7 @@ DNA 文档 -> DNA template |--------|----------| | 选题与观看理由、标题与封面写法、内容创意原型、业务植入套路、互动引导与 CTA 套路、视频内容形态与制作指向、制作规格与视听倾向、(口播类)口播文案子DNA、(对标账号)账号运营子模块 | 创作细节、脚本结构、逐句台词、镜头表、转场与编码参数——那些归 Content Producer | -视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与(口播类的)**口播文案**。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 +视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与**口播**(真人出镜 / 数字人)的**口播文案**;旁白(剪辑配的解说)由 CP 写,不在 DNA 指导范围。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 ## 维度(10 维) @@ -101,14 +101,14 @@ DNA template = **Brief.md 正文模板 + 口播文案模板(可选)**,固 | DNA 观测到的内容形态 | 制作指向(只能写真实存在的资源名) | Brief `workflow` 字段值 | |---------------------|-----------------------------------|------------------------| | 影视解说 / 剧情解说 + 反转植入(「万万没想到」式) | Content Producer `expert-video` → **Reversal Ad** workflow | `reversal-ad` | -| 口播类(真人口播出镜,或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | +| 口播 / 旁白类(真人口播或数字人出镜;或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | | 一句文稿转视觉隐喻的纸拼贴动画 | Content Producer `expert-video` → **Collage B-roll** workflow | `collage-broll` | -| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,Stage 1 定档位(narrative / motion / montage) | 省略 | +| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,据创意自定叙事 / 动效 / 蒙太奇手法 | 省略 | | 已有素材简单拼接、加旁白、烧字幕 | main `video-edit`(不委托 CP) | — | | 已有真人口播素材去口气词、剪高光 | main `talking-head-cut`(不委托 CP) | — | | 产品操作录屏 | main `ui-demo`(不委托 CP) | — | -Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,档位由 Stage 1 `intent-router` 定。 +Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。 ## Focus ID 表 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py index c9af3557..a183f64f 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py @@ -104,7 +104,7 @@ "topic-angle": "- 单篇观测:选题类型、选题入口(现象 / 问题 / 冲突 / 数据 / 热点 / 挑战 / 个人经历)、目标人群为什么要看完(理解 / 判断 / 行动 / 避坑 / 身份认同 / 情绪共鸣)。\n- 边界:只记本篇,不判断跨篇稳定性;不评价选题好坏。", "title-cover": "- 单篇观测:标题类型(痛点 / 数字 / 反差 / 悬念 / 身份点名 / 搜索长尾)、标题与描述原文、话题标签策略。\n- 视觉证据:封面或首帧必须由视觉模型读取图片,至少提取画面主体与场景、构图与画幅、色彩体系、光线与质感、风格与媒介、文字视觉与图文关系、品牌识别元素、避免项,并反推为可执行的 AIGC 提示词要素;无图片写「未提供」,不得凭正文或标题想象补齐。", "content-idea": "- 单篇观测:一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点。\n- 可复用信号:这个创意套路换成别的主题还能怎么用。\n- 边界:只记创意层,不记创作细节(逐句台词、镜头表、脚本结构、转场与编码参数)。", - "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程 + Stage 1 定档位),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", + "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程据创意自定手法),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", "production-spec": "- 单篇观测:横屏或竖屏、时长带、画面风格(色调、质感、字幕样式倾向、信息密度)、配音音色与声音形态(原声口播 / TTS / 旁白 / 纯画面字幕)、BGM 与音效倾向、封面规格。\n- 边界:只记规格与倾向,不规定镜头参数、逐镜设计、转场与编码细节——那些归 Content Producer。", "narration-script": "- 子模块(仅口播类作品启用):起(从什么起步)、承(靠什么推进)、转(转折触发)、合(收束方式;CTA 的目标、位置与句式记在 `interaction-cta`),以及人称与语气、句长与语速、签名式表达。\n- 边界:非口播类或证据不足时写「未启用 / 未观测」;不得把单篇句式直接上升为规则。", "body-voice": "- 单篇观测:开头钩子(原文摘录)、正文组织方式(清单体 / 教程步骤 / 故事线 / 对比 / 观点输出)、分行与段落节奏、口语化程度与人称、emoji 与标点用法、签名式表达。\n- 证据边界:脚本统计只给句长 / 行数 / emoji / 标签等线索;口头禅与签名表达必须回读原文确认。", @@ -144,7 +144,7 @@ } TEMPLATE_CHECKLISTS = { - "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题 / 描述、封面是否来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类是否附口播文案(或真人口播录音路径);口播子模块未启用时是否避免规定逐句口播。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", + "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题 / 描述、封面是否来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类(真人出镜 / 数字人 / 真人录音)是否附口播终稿(`voiceover.md` 绝对路径)或真人录音路径——无论口播子模块是否启用;剪辑配解说的旁白归 CP 写,Brief 口播字段写「不适用」。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", "note": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题与封面图组是否来自 DNA 文档。\n- 正文表达的每条规则是否可从 DNA 文档推导,未使用空泛形容词。\n- 图组数量、构图与视觉风格是否与 DNA 一致;视觉结论是否有图片证据。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「软性推荐」),CTA 是否每篇只放一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", } diff --git a/crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-video-publish/SKILL.md similarity index 72% rename from crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md rename to crews/main/skills/expert-douyin/tools/douyin-video-publish/SKILL.md index dafd509b..dd7a5d3a 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-publish/SKILL.md +++ b/crews/main/skills/expert-douyin/tools/douyin-video-publish/SKILL.md @@ -1,9 +1,9 @@ --- -name: douyin-publish +name: douyin-video-publish description: 通过浏览器自动化发布视频到抖音创作者中心。纯浏览器操作方案。 --- -# douyin-publish — 工具说明 +# douyin-video-publish — 工具说明 > 本文是 `expert-douyin` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 @@ -16,42 +16,11 @@ description: 通过浏览器自动化发布视频到抖音创作者中心。纯 --- -## 发布前置:open 上传页 + agent 判定登录态(必做) +## 发布前置与登录异常(必做) -抖音 cookie 存在预热机制,直接 `douyin-publish run` 可能因 cookie 未激活而不生效。**每次发布前必须先 open 上传页**(无头 persistent session),由 agent 在此页面根据元素判定登录态,之后再走 `run`。 +先读并执行[共用登录流程](../_shared/publish-login.md):`douyin-video-publish open-page` → agent 检查创作者页面登录态 → 已登录才执行 `run`。首次登录、登录弹窗、运行中 exit 2,均按该流程有头登录并交 login-manager 导出验证。 -```bash -# 1. open 上传页(无头 persistent session `douyin`) -douyin-publish open-page -# 输出: {"ok": true, "session": "douyin", "url": "...", "hint": "agent 用 camoufox-cli eval/snapshot 判定登录态"} - -# 2. agent 判定登录态 -通过页面元素判定登录态,如用户头像/用户名等。示例: -camoufox-cli --session douyin --persistent --json eval "document.querySelector('头像 selector') ? 'logged_in' : 'not_logged_in'" - -也可以直接截图调用视觉模型判定。 - -# 3a. 判定为已登录 → 走发布 -douyin-publish run --video /path/to/video.mp4 --title "标题" --caption "描述" - -# 3b. 判定为未登录 → 走「登录失效处理」 -``` - -> 脚本内 `_check_logged_in` 已 mute 成 no-op:登录态判定由 agent 在 open 上传页后自行根据页面元素完成,脚本不做检查、不因此退出。 - ---- - -## 登录失效处理 - -判定为未登录、或运行中得到 exit 2 时,走 `login-manager` 重登,复用 `douyin` 持久化 session: - -```bash -camoufox-cli --session douyin --persistent --headed --json open "https://www.douyin.com" -# 告知用户在窗口里手动完成创作者中心登录,确认后: -login-manager --platform douyin -``` - -`login-manager` 一条命令闭环导出+验证+落中央存储+close session。重登后重新走「发布前置」。本工具**没有 `login` 子命令、也没有 `cleanup` 子命令**。 +`open-page` 成功不代表已登录;脚本的 `_check_logged_in` 不做自动判断。已点击发布后的任何异常先核实管理页,不因重登直接重发。 --- @@ -60,7 +29,7 @@ login-manager --platform douyin ### 一键全流程 ```bash -douyin-publish run \ +douyin-video-publish run \ --video /path/to/video.mp4 \ --title "视频标题" \ --caption "视频描述 #话题1 #话题2" @@ -72,18 +41,18 @@ douyin-publish run \ ```bash # 1. 上传视频(返回 session 名,后续步骤用) -douyin-publish upload --video video.mp4 +douyin-video-publish upload --video video.mp4 # 2. 填标题/描述 + 自主声明 # fill 命令内部自动完成:填标题 -> 填简介 -> 选自主声明"内容由AI生成" -> 点"确定"按钮 # 自主声明下拉不存在时不阻断(部分账号/页面无此选项) -douyin-publish fill --session --title "标题" --caption "描述" +douyin-video-publish fill --session --title "标题" --caption "描述" # 3. 点发布(返回发布起始时刻,供 get-link 锁定本次作品) -douyin-publish publish --session +douyin-video-publish publish --session # 4. 取视频链接 -douyin-publish get-link --session +douyin-video-publish get-link --session ``` ### 行为说明 @@ -127,7 +96,7 @@ douyin-publish get-link --session - **触发**:访问 `creator.douyin.com` 未登录态 - **症状**:页面跳到 `creator.douyin.com/login` 或出现登录弹窗 -- **workaround**:脚本返回 `exit 2`,调用方走 `login-manager` 有头手动重登流。 +- **workaround**:agent 根据页面或 exit 2 判断后,按共用登录流程处理;不要依赖脚本自动识别登录弹窗。 ### pitfall: real_name_auth_required diff --git a/crews/main/skills/expert-douyin/tools/douyin-publish/douyin-publish.sh b/crews/main/skills/expert-douyin/tools/douyin-video-publish/douyin-video-publish.sh similarity index 76% rename from crews/main/skills/expert-douyin/tools/douyin-publish/douyin-publish.sh rename to crews/main/skills/expert-douyin/tools/douyin-video-publish/douyin-video-publish.sh index fa5c3eca..c2fe63fa 100755 --- a/crews/main/skills/expert-douyin/tools/douyin-publish/douyin-publish.sh +++ b/crews/main/skills/expert-douyin/tools/douyin-video-publish/douyin-video-publish.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# douyin-publish — 抖音发布 wrapper -# 让 agent 用 `douyin-publish ` 走 PATH,零路径拼接。 +# douyin-video-publish — 抖音发布 wrapper +# 让 agent 用 `douyin-video-publish ` 走 PATH,零路径拼接。 # 直调 scripts/publish_douyin.py(Python 3 stdlib + camoufox-cli)。 set -euo pipefail SELF="${BASH_SOURCE[0]}" diff --git a/crews/main/skills/expert-douyin/tools/douyin-publish/scripts/publish_douyin.py b/crews/main/skills/expert-douyin/tools/douyin-video-publish/scripts/publish_douyin.py similarity index 96% rename from crews/main/skills/expert-douyin/tools/douyin-publish/scripts/publish_douyin.py rename to crews/main/skills/expert-douyin/tools/douyin-video-publish/scripts/publish_douyin.py index 4584d4ba..24a0bd9a 100755 --- a/crews/main/skills/expert-douyin/tools/douyin-publish/scripts/publish_douyin.py +++ b/crews/main/skills/expert-douyin/tools/douyin-video-publish/scripts/publish_douyin.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""douyin-publish - 抖音内容发布(纯浏览器模拟方案,形态仿 wechat-channels-publish) +"""douyin-video-publish - 抖音内容发布(纯浏览器模拟方案,形态仿 wechat-channels-publish) 形态与 wechat-channels-publish 同构:纯浏览器操作,走 forked camoufox-cli 持久化 session `douyin` + upload 命令,在创作者中心页面填表 + 上传视频 + 发布。 @@ -39,6 +39,9 @@ from pathlib import Path from typing import Optional +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / '_shared')) +from publish_browser import publish_lock, Browser + # ── 常量 ───────────────────────────────────────────────────────────────────── UPLOAD_URL = "https://creator.douyin.com/creator-micro/content/upload?enter_from=dou_web" @@ -64,8 +67,9 @@ def session_name(purpose: str = "publish") -> str: def camoufox_open(session: str, url: str) -> None: """启 persistent 会话 + 打开 URL(camoufox-cli 默认 headless)。""" - cmd = [CAMOUFOX_BIN, "--session", session, "--persistent", "--json", "open", url] - subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False) + if session != PERSISTENT_SESSION: + raise ValueError('发布必须复用 session douyin') + Browser().command('open', url) # 抖音登录态关键 cookie(与 _shared/check-session.ts Tier1 一致:sessionid+sid_tt+uid_tt 必须全在)。 @@ -97,7 +101,7 @@ def _dismiss_draft_dialog(session: str) -> None: ) == "yes" if not has_dialog: return - sys.stderr.write("[douyin-publish] 检测到上次未发布草稿,点「放弃」清掉后重新上传...\n") + sys.stderr.write("[douyin-video-publish] 检测到上次未发布草稿,点「放弃」清掉后重新上传...\n") if not camoufox_click_leaf_by_text(session, "放弃"): sys.stderr.write("warn: 草稿弹窗「放弃」按钮未点到,继续上传(可能受弹窗干扰)\n") return @@ -274,7 +278,7 @@ def cmd_open_page(*, session: Optional[str] = None) -> None: 新流程(2026-08-04):agent 先调本命令 open 上传页,再用 camoufox-cli eval/snapshot 根据页面元素(用户头像/用户名是否存在、是否跳 /login)判定登录态。判定为已登录后 - 调 `douyin-publish run` 走发布;判定为未登录则走 login-manager 有头重登。 + 调 `douyin-video-publish run` 走发布;判定为未登录则走 login-manager 有头重登。 本命令只 open + 输出 session 名 + 当前 URL,不做任何登录态判定(原 _check_logged_in 已 mute,误判率高)。 @@ -298,6 +302,8 @@ def cmd_upload(*, video: str, session: Optional[str] = None) -> None: if not session: session = PERSISTENT_SESSION video_path = Path(video).resolve() + if video_path.suffix.lower() not in {".mp4", ".mov"}: + raise ValueError("--video 仅支持 mp4/mov") if not video_path.is_file(): sys.stderr.write(f"error: video not found: {video_path}\n") sys.exit(1) @@ -313,7 +319,7 @@ def cmd_upload(*, video: str, session: Optional[str] = None) -> None: sys.stderr.write("error: 上传 input 未找到或 upload 注入失败(DOM 改版?)\n") sys.exit(1) - sys.stderr.write("[douyin-publish] 视频已注入,等待上传/转码...\n") + sys.stderr.write("[douyin-video-publish] 视频已注入,等待上传/转码...\n") # 上传+转码完成的真实信号是表单渲染出来(标题 input 出现),而非页面文本"上传成功"-- # 抖音上传页根本没有"上传成功"这四个字,旧写法必超时。2026-07-17 真机 spike 确认。 if not camoufox_wait_for_selector(session, 'input[placeholder*="填写作品标题"]', TRANSCODE_MAX_WAIT_S): @@ -458,7 +464,7 @@ def cmd_publish(*, session: str) -> None: if not camoufox_click_button_by_text(session, "发布"): sys.stderr.write("error: 发布按钮未找到(DOM 改版?)\n") sys.exit(1) - sys.stderr.write("[douyin-publish] 已点发布,等待跳转...\n") + sys.stderr.write("[douyin-video-publish] 已点发布,等待跳转...\n") # 发布成功后页面跳转到作品管理页 /content/manage(中间会闪"正在发布"转圈 toast)。 # 没有"发布成功"文本,旧 wait_for_text 必超时。2026-07-17 真机 spike 确认。 if not camoufox_wait_for_url_contains(session, "/creator-micro/content/manage", POST_PUBLISH_MAX_WAIT_S): @@ -472,7 +478,7 @@ def cmd_publish(*, session: str) -> None: aweme_id, title = _fetch_newest_aweme_id(session, since_ts=publish_start - 120) if aweme_id: sys.stderr.write( - f"[douyin-publish] work_list API 取到最新作品 aweme_id={aweme_id} title={title!r}\n" + f"[douyin-video-publish] work_list API 取到最新作品 aweme_id={aweme_id} title={title!r}\n" ) # 落 localStorage 供 get-link 复用(跨导航存活) camoufox_eval( @@ -484,7 +490,7 @@ def cmd_publish(*, session: str) -> None: debug_entries = _read_publish_debug(session) try: Path(debug_path).write_text(json.dumps(debug_entries, ensure_ascii=False, indent=2), "utf-8") - sys.stderr.write(f"[douyin-publish] debug 日志已写 {debug_path}({len(debug_entries)} 条请求,请回传给研发)\n") + sys.stderr.write(f"[douyin-video-publish] debug 日志已写 {debug_path}({len(debug_entries)} 条请求,请回传给研发)\n") except Exception as e: sys.stderr.write(f"warn: debug 日志写盘失败: {e}\n") # aweme_id 没捕获到 → 发布可能未真正成功(拦截器没命中真实发布 API,或发布被服务端拒了)。 @@ -571,7 +577,7 @@ def _fetch_newest_aweme_id(session: str, since_ts: Optional[int] = None) -> tupl # sc != 0 → 鉴权间歇失败,短等重试 last_sc = sc sys.stderr.write( - f"[douyin-publish] work_list status_code={sc}(attempt {attempt + 1}/3),间歇鉴权失败,重试...\n" + f"[douyin-video-publish] work_list status_code={sc}(attempt {attempt + 1}/3),间歇鉴权失败,重试...\n" ) time.sleep(2) # 3 次都 sc!=0 → session 失效,交调用方重登 @@ -633,7 +639,7 @@ def cmd_get_link(*, session: str) -> None: aweme_id, title = _fetch_newest_aweme_id(session) if aweme_id: url = "https://www.douyin.com/video/" + aweme_id - sys.stderr.write(f"[douyin-publish] get-link 走 work_list 兜底:aweme_id={aweme_id} title={title!r}\n") + sys.stderr.write(f"[douyin-video-publish] get-link 走 work_list 兜底:aweme_id={aweme_id} title={title!r}\n") sys.stdout.write(json.dumps({"ok": True, "url": url, "aweme_id": aweme_id}, ensure_ascii=False)) sys.stdout.write("\n") return @@ -687,7 +693,7 @@ def cmd_run(*, video: str, title: str, caption: str = "") -> None: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( - prog="publish_douyin", + prog="douyin-video-publish", description="抖音内容发布(纯浏览器模拟方案,形态仿 wechat-channels-publish。探活/有头登录/导出 cookie+UA 交 login-manager)", ) sub = p.add_subparsers(dest="cmd", required=True) @@ -728,7 +734,8 @@ def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: - args.func(args) + with publish_lock(): + args.func(args) return 0 except SystemExit as e: return int(e.code) if e.code is not None else 0 diff --git a/crews/main/skills/expert-douyin/workflows/account-benchmark.md b/crews/main/skills/expert-douyin/workflows/account-benchmark.md index 885e1bfb..34f4f995 100644 --- a/crews/main/skills/expert-douyin/workflows/account-benchmark.md +++ b/crews/main/skills/expert-douyin/workflows/account-benchmark.md @@ -1,5 +1,8 @@ # 抖音账号对标 Workflow + +`douyin-comments` 返回 exit 3 时停止本轮抖音评论采样,标注评论数据不可得或样本不完整,继续其他分析;不把接口错误当作零评论或登录失效,不强制重登。 + 用于分析对标账号或一组对标视频,形成独立 DNA,并与默认或指定 DNA 逐项比较。对标样本不得直接写入 `dna-0`;用户采纳后,只有转译后的规则可融合进基线 DNA。 ## 入口判断 diff --git a/crews/main/skills/expert-douyin/workflows/account-setup.md b/crews/main/skills/expert-douyin/workflows/account-setup.md index 532fbbef..e7ec1adb 100644 --- a/crews/main/skills/expert-douyin/workflows/account-setup.md +++ b/crews/main/skills/expert-douyin/workflows/account-setup.md @@ -1,8 +1,11 @@ # 起号、定位梳理与老号接手 Workflow + +`douyin-comments` 返回 exit 3 时停止本轮抖音评论采样,标注评论数据不可得或样本不完整,继续其他分析;不把接口错误当作零评论或登录失效,不强制重登。 + 新号起号、账号定位梳理、内容支柱搭建、默认 `dna-0` 初始化、老号接手与诊断走这个 Workflow。独立账号对标走 `account-benchmark.md`。 -起号拆成六件事:定位清楚、观看理由成立、标签稳定、内容有用、互动真实、复盘持续;平台功能入口、算法权重、处罚规则按待确认信息处理。产出要能直接执行:表格、清单、脚本简报、选题池或复盘动作,少写空泛建议,多给"下一条视频该怎么做"。 +起号拆成六件事:定位清楚、观看理由成立、标签稳定、内容有用、互动真实、复盘持续;平台功能入口、算法权重、处罚规则按待确认信息处理。产出要能直接执行:表格、清单、话术简报、选题池或复盘动作,少写空泛建议,多给"下一条视频该怎么做"。 ## 入口判断 diff --git a/crews/main/skills/expert-douyin/workflows/content-production.md b/crews/main/skills/expert-douyin/workflows/content-production.md index fc084a80..8c4a1b45 100644 --- a/crews/main/skills/expert-douyin/workflows/content-production.md +++ b/crews/main/skills/expert-douyin/workflows/content-production.md @@ -6,26 +6,29 @@ ## Step 0 - 入口判断 +### 0. 确定交付形态 + +先按用户需求与素材确定 `video` 或 `note`,不确定时再问。用户要求图文、图片卡片或小红书图组同步抖音时走 `note`;不要为调用视频工具而把图片强制合成视频。只发布已有成品可直接到 Step 6,保留成品确认与发布记录。 + ### 1. 识别用户输入类型 | 输入 | 模式 | | --- | --- | | 只有粗略想法或方向 | 想法模式 | -| 参考视频(抖音链接或本地文件) | 参考模式 | +| 参考作品(图文 / 视频链接或本地文件) | 参考模式 | | 已有素材(视频片段、图片、录音、产品演示) | 素材模式 | | 已有脚本(用户写好或别的来源) | 脚本模式 | ### 2. 识别制作路线 -| 路线 | 判断 | 执行方 | -| --- | --- | --- | -| 素材组装 / 轻剪辑 | 用户手里有可用素材 | main 直接做:`video-edit` / `talking-head-cut` / `ui-demo` | -| 从零制作 | 没有素材,需要出脚本、拍摄/生成画面 | main 出制作简报,委托 `content-producer` | -| 脚本制作 | 用户已有脚本 | 根据dna对脚本做必要修改,提交用户确认后,脚本交 `content-producer` 制作 | +先选图文或视频两条主路线: + +- **图文** → Step 5A:main 按图文 DNA 制作图组与正文;复杂视觉设计可委托 content-producer。 +- **视频** → Step 5B:仅素材组装或轻剪辑由 main 直接做;其余视频制作均出 Brief,按标准流程委托 content-producer。有脚本仍按视频路线判断,不另立制作路线;脚本作为素材保留,涉及新叙事或分镜交 CP。 ### 3. 抖音链接的意图判断 -用户给抖音视频链接时,先判断意图: +用户给抖音视频或图文链接时,先判断意图: - **风格吸收**:"把这条的风格提取出来""以后照着这个味道做" -> 切换到 `style-dna.md`,生成 report 并更新 DNA。 - **内容参考**:"照着这条做一条""这个选题我们也做一条" -> 继续本流程(参考模式)。 @@ -33,7 +36,7 @@ ### 4. 与改片的边界 -用户对一条已完成的视频(通常是我们此前产出的)改文案、重新剪辑、换封面,走 `editing.md`。本流程把用户输入当作素材,走完整生产链路产出新视频。 +用户对已有图文或视频改文案、调整图组、剪辑或换封面,走 `editing.md`。本流程用于产出一篇新图文或一条新视频。 ## Step 1 - 生产契约锁定 @@ -42,7 +45,7 @@ 生产必须绑定一个 DNA: 1. 用户明确指定 `dna-id` 时,只用该 DNA。 -2. 用户没有指定时,用默认 `dna-0`。 +2. 用户没有指定时,视频用 `dna-0`,图文用独立图文 DNA(如 `dna-0-note`);不可混用视频 DNA。 3. 不得临场凭感觉拼一个风格。 4. 若目标 DNA 不存在,先按 `account-setup.md` / `style-dna.md` 建立或更新 DNA;完成前不进入制作。 5. 用户意图是把参考视频的风格吸收进 DNA 时,先按 `style-dna.md` 处理,完成 DNA 更新后再回到本流程;仅参考主题内容时直接按本流程生产,不动 DNA。 @@ -76,11 +79,13 @@ DNA template 是 main agent 的生产输入模板: | 项目 | 规则 | | --- | --- | -| 制作路线 | 素材组装 / 从零制作 / 脚本制作,判断依据见 Step 0 | +| 作品形态与制作路线 | 图文或视频(直接组装 / 轻剪辑、委托 CP),判断依据见 Step 0 | +| workflow | 视频全案已确定形态时写 CP `expert-video` 支持的 workflow(reversal-ad / narration-video / collage-broll);未确定则省略(省略 = CP 按其通用制作流程做) | | 主题 / 方向 | 用户给了明确主题时不得另起炉灶,仅按 DNA template 细化选题和钩子 | | 素材 | 用户提供的视频片段、图片、录音、文案必须优先使用 | | 目标观众 | 未指定时按 `business_knowledge.md` 和 DNA 受众关系推导 | -| 时长 | 未指定时按 DNA template 的时长带;再无要求默认 30-90 秒 | +| 视频时长 | 仅视频:未指定时按 DNA template 的时长带;再无要求默认 30-90 秒 | +| 图文图组 | 仅图文:确定图片数量、图序、逐页信息与首图要求,按图文 template 制作 | | 封面 | 默认生成;用户自带封面时优先使用 | | 简介与话题标签 | 用户指定时逐字使用;未指定时按 DNA template 标题与文案维度推导 | @@ -98,10 +103,10 @@ DNA 约束的是选题与观看理由、标题与封面写法、内容创意原 ## Step 2 - 素材获取与整理 -先建立视频目录 `douyin/outputs//`(video-name 用主题的短 slug;选题尚未确定时可先用暂代名,选题确定后随之定名),下设 `materials/` 子目录,获取到的素材统一放入 `materials/`。 +先建立作品目录 `douyin/outputs//`(work-name 用主题的短 slug;选题尚未确定时可先用暂代名,选题确定后随之定名),下设 `materials/` 子目录,获取到的素材统一放入 `materials/`。 1. 按类型获取输入: - - 抖音参考视频链接 -> self-spawn subagent 走 `viral-chaser` 拆解,转录、关键帧、时长、互动线索落入 `douyin/outputs//references/`(仿照制作时它就是参考素材)。 + - 抖音参考视频链接 -> self-spawn subagent 走 `viral-chaser` 拆解,转录、关键帧、时长、互动线索落入 `douyin/outputs//references/`(仿照制作时它就是参考素材)。 - 本地视频 / 图片 / 音频素材 -> 复制进 `materials/`。 - 用户文字输入(想法、脚本、要点)-> 保存为 `.md` 放入 `materials/`。 2. 建立素材清单:用户原话、事实、数据、案例、画面素材、可用观点、待确认信息。 @@ -161,7 +166,7 @@ DNA 约束的是选题与观看理由、标题与封面写法、内容创意原 - 有参考视频或用户文案时,必须参考其标题内容(如有),但不得照抄。 - 用户已指定标题时,候选必须在该约束内生成,不得偷换方向。 -- 标题硬限制:不超过 30 字(抖音创作者平台上限)。 +- 标题限制:视频 ≤30 字;图文 ≤20 字、描述(含话题)≤1000 字。 - 简介提及产品或业务,但不放明显引流信息;禁止二维码、联系方式;可引导用户主动搜索或点头像看主页。 - 话题标签按 DNA template 的策略组织:主标签 + 场景词 + 痛点词,不堆砌。 - 业务植入与 CTA 按 DNA 的 `biz-implant` / `interaction-cta` 落位:植入位置、载体与衔接句写清楚,一条只放一个主行动,不堆叠 CTA。 @@ -172,9 +177,21 @@ DNA 约束的是选题与观看理由、标题与封面写法、内容创意原 ## Step 5 - 制作 -按 Step 0 判定的路线执行。所有路线的成品最终落在 `douyin/outputs//`。 +按 Step 0 判定的路线执行。所有路线的成品最终落在 `douyin/outputs//`。 + +### Step 5A - 图文制作 + +按图文 DNA template 编排封面、逐页内容、阅读顺序、正文与 CTA。已有跨平台图组优先复用,检查事实、裁切、平台文案与授权;缺图用 `awk-img-gen`,复杂设计交 content-producer。交付有序图片清单(1–35 张,单张 ≤50MB,jpg/jpeg/png/webp/bmp/tif,建议 3:4 或 4:3)及标题、描述。配乐只确定风格意图;上传后根据抖音实际推荐候选选择歌曲。 + +#### 【确认】图组与正文 + +确认完整图组(含首图)、图片顺序、正文与配乐风格意图。确认后直接进入 Step 6,不执行视频制作、视频质检或“成片与封面确认”。 -### 路线 A:素材组装 / 轻剪辑(main 直接做) +### Step 5B - 视频制作 + +视频仅分以下两种执行方式。 + +#### 方式 1:素材组装 / 轻剪辑(main 直接做) 只处理已有素材的简单加工,不升格为全案制作: @@ -185,16 +202,16 @@ DNA 约束的是选题与观看理由、标题与封面写法、内容创意原 5. 产品操作演示 -> `ui-demo` 录制。 6. 若需求超出“简单加工”(需要重写叙事、全新分镜、全案生成),停止自做,改走 Brief 委托路线。 -### 路线 B / C:委托 content-producer 制作 +#### 方式 2:其他视频制作(按标准流程委托 content-producer) -1. 产出**制作简报** `douyin/outputs//brief.md`(Brief 是 main / CP 的唯一交接物): +1. 产出 **Brief** `douyin/outputs//brief.md`(Brief 是 main / CP 的唯一交接物): ```markdown # 抖音视频制作 Brief - 视频名 / slug: - platform:douyin -- workflow:reversal-ad / narration-video / collage-broll / 未指定(未指定 = CP 按其通用制作流程做,Stage 1 自定档位) +- workflow:reversal-ad / narration-video / collage-broll(视频形态未确定时省略本字段;省略 = CP 按其通用制作流程做,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定) - 选题与观看理由: - 核心传达: - 内容创意:创意原型 + 展开逻辑 + 记忆点(+ 反转设计,如为反转植入类) @@ -202,7 +219,7 @@ DNA 约束的是选题与观看理由、标题与封面写法、内容创意原 - 标题与简介:发布标题、简介文案、话题标签(main 定稿) - 封面要求:封面主文案 + 视觉方向 - 制作规格:横屏 / 竖屏、时长带、画面风格、配音音色与声音形态、BGM 与音效、字幕 -- 口播文案:`voiceover.md` 绝对路径(口播类必填)/ 真人口播录音绝对路径 / 不适用 +- 口播文案:`voiceover.md` 绝对路径 / 真人口播录音绝对路径 / 不适用(口播 = 真人出镜、数字人、真人录音;剪辑配解说的**旁白归 CP 写**,写「不适用」) - 素材清单:逐条**绝对路径** + 来源 + 授权(无素材时写「无,由 CP 按 Brief 取材」) - 交付物与验收:`video.mp4` + `cover.jpg` + `final-deliver.md`,回报三者绝对路径;验收标准 - 闸门:GATE A / GATE B 批准人(用户或 main 代理批准 + 批准范围) @@ -216,44 +233,58 @@ Brief 硬性规则: - **素材给绝对路径**:main 负责素材准备(用户素材预处理、`ui-demo` 录屏、从 `campaign_assets/` 挑选),把绝对路径写进 Brief。 - **甲乙方关系**:需求方向、品牌事实、发布文案归 main;制作方案、分镜、渲染参数归 CP。 -2. 口播类视频:按 DNA 的 `narration-script` 子模块写口播终稿 `douyin/outputs//voiceover.md`,Brief 里给绝对路径;真人口播时指导用户按口播稿录音,完成后向用户取得录音文件。 +2. 口播(真人出镜 / 数字人 / 真人录音)视频:口播稿一律由 main 写——`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写——落 `douyin/outputs//voiceover.md`,Brief 里给绝对路径;真人口播时指导用户按口播稿录音,完成后向用户取得录音文件。剪辑配解说的旁白由 CP 写,main 不出旁白稿。 3. 参考模式下,把选题与创意结论写进 Brief 的「内容创意」段即可;`viral-chaser` 拆解报告是 main 的采样材料,**不作为 Brief 附件交给 CP**。 -4. spawn `content-producer` 委托制作:只交 Brief + 素材绝对路径 + 口播文案 / 录音;不指定 CP 的工作区与制作方案。 +4. spawn `content-producer` 委托制作:只交 Brief 一份——素材、口播文案 / 录音均已以绝对路径写在 Brief 内;不指定 CP 的工作区与制作方案。 5. Brief 变更时更新版本并推送变更要点;已开工中间产物按新版取舍,弃用部分记入交付说明。 -6. CP 交付后,按其回报的绝对路径把成片与封面取回 `douyin/outputs//`(`video.mp4` / `cover.jpg`),并把交付说明要点记入作品目录。 +6. CP 交付后,按其回报的绝对路径把成片与封面取回 `douyin/outputs//`(`video.mp4` / `cover.jpg`),并把交付说明要点记入作品目录。 -## 【确认】成片与封面 +#### 【确认】成片与封面(仅视频) -第三个必停节点。成片与封面确认后才可发布。用户有意见按意见修改,直至确认。 +main 自做的素材组装 / 轻剪辑先通过 `video-review`;CP 交付按其标准流程完成质检。用户确认成片与封面后进入 Step 6;有修改意见则回对应执行方式调整。 ## Step 6 - 发布 -发布走 `douyin-publish`(工具说明见包内 `douyin-publish` 文档): +按成品形态选择工具,先读对应工具说明及其引用的共用登录流程;打开页面并确认登录态后再发布。视频示例: ```bash # 1. open 上传页 + agent 判定登录态(必做,不可跳过) -douyin-publish open-page +douyin-video-publish open-page # 用页面元素(用户头像/用户名)判定登录态;未登录走 login-manager --platform douyin 有头重登 # 2. 发布 -douyin-publish run --video douyin/outputs//<成片文件> --title "标题" --caption "简介 #话题1 #话题2" +douyin-video-publish run --video douyin/outputs//<成片文件> --title "标题" --caption "简介 #话题1 #话题2" +``` + +图文示例: + +```bash +douyin-note-publish open-page +# 判定登录态后上传;音乐候选由上传后的页面提供 +douyin-note-publish upload --images /path/cover.png /path/page2.png +douyin-note-publish music-list +# 根据作品内容选择实际候选,复制其 choice +douyin-note-publish music-select --choice "实际候选choice" +douyin-note-publish fill --title "图文标题" --caption "描述 #话题" +douyin-note-publish publish +douyin-note-publish get-note-link --title "图文标题" ``` - 发布前必做 `open-page` + 登录态判定,否则可能因 cookie 未预热而不生效。 - AIGC 生成的内容按平台规则标注:`fill` 已内置自主声明"内容由AI生成",无需额外操作;纯实拍素材不声明。 -- exit 2(登录失效)-> `login-manager --platform douyin` 有头重登后重试;exit 3(未捕获 aweme_id)-> 人工到创作者中心管理页核实是否真有新作品,不得当作发布成功。 -- 同一时间只能有一个 `douyin-publish` 发布任务在跑(浏览器 session 竞态),多平台分发时抖音这条必须串行。 +- 登录异常按工具引用的共用登录流程处置。已点击发布后遇 exit 2 / exit 3 / 超时,先核实管理页并补取链接,不直接重跑 `run` 或 `publish`。 +- 图文和视频合计同一时间只能有一个发布任务在跑(浏览器 session 竞态),多平台分发时抖音这条必须串行。 - 限频:单抖音号每 24h ≤ 5 条;触发风控立即降级,30 分钟内不重试。 ## Step 7 - 记录 -发布成功(拿到视频链接)后入库: +发布成功(拿到与成品类型匹配的视频 `/video/` 或图文 `/note/` 链接)后入库: -1. 写 `douyin/outputs//dna-meta.json`: +1. 写 `douyin/outputs//dna-meta.json`: ```json {"platform": "douyin", "dna_id": ""} ``` -2. 调 `published-track record`:`--platform douyin`、`--source-folder douyin/outputs//`、`--account <发布所用账号 alias>`、`--publish-url `(`dna_id` 自动从 `dna-meta.json` 读取)。 -3. 发布流程到此结束;互动数据由每日定时采集任务统一抓取,复盘走 `review.md`。 +2. 调 `published-track record`:`--platform douyin`、`--source-folder douyin/outputs//`、`--account <发布所用账号 alias>`、`--publish-url <对应发布工具返回的 url>`;图文 `--content-type post`、视频 `--content-type video`(`dna_id` 自动从 `dna-meta.json` 读取)。 +3. exit 3 仅补取链接或人工核实,禁止自动重新发布。发布流程到此结束;互动数据由 heartbeat 的 `published-track query --platform douyin --limit 30` + 逐条 `fetch-metrics --platform douyin --id ` 统一抓取(自动识别 note/video),复盘走 `review.md`。 diff --git a/crews/main/skills/expert-douyin/workflows/editing.md b/crews/main/skills/expert-douyin/workflows/editing.md index 312f1395..11e5f100 100644 --- a/crews/main/skills/expert-douyin/workflows/editing.md +++ b/crews/main/skills/expert-douyin/workflows/editing.md @@ -1,61 +1,58 @@ -# 改片与调整 Workflow +# 图文与视频修改 Workflow -用户说"帮我改改这条""文案重写一下""剪紧一点""换个封面"走这个。 +用户要求修改已有作品的标题、正文、图组、剪辑、配乐或封面时走本流程。先定位原作品目录、作品形态(`note` / `video`)、DNA 绑定及本次修改范围;用户要求新主题或整体换方向时回到 `content-production.md`。 -抖音平台不支持替换已发布视频的文件;所有"改"都作用在我们的成片或素材上,改完的成品如需上线是一次**新发布**(重复内容重新发布有去重与风控风险,先跟用户说清再发)。 +这里先修改本地作品。两个发布工具执行的是新发布,不能用 `run` 当作线上原帖编辑;用户明确要求修改已发布原帖时,先核实平台允许的修改项与操作方式,不自动转成重新发布。 -## 入口判断 +## Step 1 - 确认作品与修改范围 -先搞清楚改的是哪个层级,不要上来就大改: +1. 读取 `douyin/outputs//` 的文案、素材及 `dna-meta.json`,沿用该作品的 DNA;无绑定时,视频选视频 DNA(默认 `dna-0`),图文选独立图文 DNA(如 `dna-0-note`)。读取相应 DNA 文档与 template,不混用。 +2. 明确用户要修改的部分、必须保留的内容以及是否需要发布。一次局部修改不默认重做整篇作品。 +3. 按作品形态进入图文或视频路线;共有文案修改先按下节处理。 -| 用户的说法 | 改的层级 | 怎么走 | -|-----------|---------|--------| -| "改下标题 / 简介 / 话题" | 文案层 | 按 DNA `title-cover` 重写,直接给候选 | -| "剪紧一点 / 去口气词 / 把高光剪出来" | 轻剪层 | `talking-head-cut`(去口气词、结巴、静音,按发言内容剪高光) | -| "加 BGM / 加字幕 / 补片头片尾 / 插一段素材" | 加工层 | `video-edit`(audio-mix / subtitles / extract / assemble) | -| "结构调一下 / 把第二段提前 / 换个讲法" | 结构层 | 先出调整方案(新的段落顺序与时点)-> 确认 -> `video-edit` 重剪 | -| "换个封面" | 封面层 | 成片抽帧或 `siliconflow-img-gen` 按 DNA `production-spec`(画面风格)重做 | -| "换个风格 / 方向不对" | 方向层 | 回到 `content-production.md` 从选题重新走 | +## Step 2 - 标题与发布文案(两种形态共用) -## 文案层 +- 用户已给定替换文案时按原文修改;需要代拟时按目标 DNA `title-cover` 和 template 给候选。 +- 图文标题 ≤20 字、描述(含 `#话题`)≤1000 字,正文语气按 `body-voice`;视频标题 ≤30 字,简介与话题按视频 template。 +- 用户选定后更新作品目录的文案记录;核对事实、产品信息、CTA 与首图 / 视频封面承诺一致。 -标题、简介、话题标签按目标 DNA `title-cover` 重写: +## Step 3A - 图文修改 -1. 目标 DNA 不确定时先确认(用户指定或默认 `dna-0`)。 -2. 标题 ≤ 30 字;简介与话题按 template 策略;禁止引流信息。 -3. 给 2-3 个候选并说明各自侧重的标题类型,用户选定后更新 `douyin/outputs//` 的文案记录。 +按本次要求修改图组或正文: -## 轻剪层与加工层 +| 修改要求 | 执行方式 | +| --- | --- | +| 改正文、逐页文字 | 按 `body-voice` 修改,同步对应图片上的文字,核对事实和前后表述 | +| 增删图片、调整顺序 | 按 `content-idea` / `imageset-visual` 整理新的图序与逐页信息;涉及叙事变化先确认调整方案 | +| 换首图、改版式或配色 | 按 `title-cover` / `imageset-visual` 调整;用户给图优先采用,需生成图片用 `awk-img-gen`,复杂设计出 Brief 委托 content-producer | +| 换配乐 | 记录期望风格;上传后用 `music-list` 读取实际候选,再用 `music-select --choice` 选曲并验证;明确使用原声时传 `publish --original-sound`,不为图文调用视频音轨工具 | -改前确认素材位置(`douyin/outputs//` 下的成片与原始素材)。 +改完检查:图序与文字对应、图片清晰度与裁切、首图与正文一致、无错字或缺页;图组 1–35 张,单张 ≤50MB,格式 jpg/jpeg/png/webp/bmp/tif。 -- 口播类去口水、剪高光 -> `talking-head-cut`,剪拼结果交给 `video-edit apply-cut`。 -- 加旁白 / BGM / 字幕 / 抽段拼接 -> `video-edit`。 -- 画面集锦类重剪:`video-edit frames` 抽帧 -> agent 看图写 cut_plan -> `video-edit apply-cut`。 +### 【确认】修改后的图组与正文 -改完成片必须过 `video-review` 闸门,verdict fail 不带病交付。 +向用户展示修改后的完整图组(含首图)、图序、正文及配乐风格意图(实际曲目上传后选择),并说明改动。确认后进入 Step 4;图文不走 video-review 或“成片与封面”确认。 -## 结构层 +## Step 3B - 视频修改 -先出结构调整方案(新的段落顺序、每段时点、删留内容),用户确认后再动剪。 -结构调整先核对 `content-idea`(内容创意)与 `video-form`(视频形态);若口播文案子模块已启用,再按 `narration-script` 核对口播结构。不要把 DNA 扩写成逐镜规则——镜头与剪辑细节归 Content Producer。 +### 素材组装 / 轻剪辑:main 直接做 -## 换封面 +- 去口气词、结巴、静音或剪高光 → `talking-head-cut`,按其输出交 `video-edit apply-cut`。 +- 抽段拼接、局部顺序调整、加旁白 / BGM / 字幕、补已有片头片尾 → `video-edit`;涉及段落变化先给删留与时点方案,确认后执行。 +- 换视频封面:用户给图优先使用;从片子抽帧提供候选,或按视频 DNA `production-spec` 用 `awk-img-gen` 重做。 -1. 用户直接指定封面图 -> 直接使用。 -2. 用户说"从片子里选一帧" -> 抽帧候选给用户挑。 -3. 用户说"重做一张" -> 按 DNA `production-spec`(画面风格)与本次标题/核心收益,用 `siliconflow-img-gen` 生成,用户确认后替换 `cover.jpg`。 +### 其他视频改制:委托 content-producer -## 改完必做 +需要新叙事、重做分镜、生成新画面或整体改制时,按 `content-production.md` Step 5B 的标准 Brief 流程委托 CP。Brief 明确修改目标、保留内容、原成片与素材的绝对路径、交付物与验收要求;main 不自行重写分镜。口播稿由 main 提供,剪辑解说旁白由 CP 写。 -1. 自己过一遍质量自检(钩子是否兑现、结构是否完整、音画是否同步)。 -2. 跟用户说清楚改了什么、为什么这么改。 -3. 改片结论属于可复用风格偏好时,按 `style-dna.md` 的反馈回流判定更新 DNA;单次修改不动 DNA。 +### 【确认】修改后的成片与封面 -## 改后发布 +main 自做组装 / 轻剪辑通过 `video-review` 后交付;CP 改制按其标准流程完成质检。核对钩子兑现、结构和音画同步,向用户说明修改内容。用户确认成片与封面后进入 Step 4。 -用户要求改完后发布 / 重新发布时,走 `content-production.md` 的发布与记录流程(Step 6-7): +## Step 4 - 交付、发布与记录 -1. **发布**:`douyin-publish open-page` + 登录态判定后 `run`;标题与文案用改后版本。 -2. **记录**:`published-track record` 重新入库为新记录(新链接、新发布日期),`dna-meta.json` 沿用该视频目录的 DNA 绑定。 -3. 重新发布前提醒用户:旧作品是否自行在平台删除由用户决定,Agent 不代删。 +1. 保留修改后的作品和文案记录;可复用风格偏好按 `style-dna.md` 反馈回流,单次修改不默认改变 DNA。 +2. 用户只要求修改时,到交付结束。用户要求发布 / 重新发布时,进入 `content-production.md` Step 6–7:图文选 `douyin-note-publish`,视频选 `douyin-video-publish`,先执行对应工具引用的共用登录流程。 +3. 已发布作品改后重新上线会产生新作品链接;发布前确认用户要重新发布。原作品是否删除由用户决定,不自动删除。 +4. 成功取得新链接后用 `published-track record` 记录新发布日期、链接和账号;图文 `--content-type post`、视频 `--content-type video`。用新作品目录保存这次发布版本与 `dna-meta.json`,避免同目录同日 upsert 覆盖旧作品记录;修改未换 DNA 时沿用原绑定。 +5. 已点击发布后发生登录异常、超时或取链失败,先核实管理页 / 补取链接,不自动重发。 diff --git a/crews/main/skills/expert-douyin/workflows/style-dna.md b/crews/main/skills/expert-douyin/workflows/style-dna.md index 99dbf19a..060f2835 100644 --- a/crews/main/skills/expert-douyin/workflows/style-dna.md +++ b/crews/main/skills/expert-douyin/workflows/style-dna.md @@ -156,7 +156,7 @@ douyin-style-profiler update \ - **图文内容**:读取图文 DNA 文档与 template,main agent 直接生产。 - **视频全案**:读取视频 DNA 文档与 template,main agent 产出 **Brief**(+ 口播类的口播文案)。Brief 写明选题与观看理由、标题与简介、内容创意、`workflow`(视频形态的制作指向)、制作规格(横竖屏 / 时长带 / 画面风格 / 配音音色)、素材清单与授权(绝对路径)、验收标准、闸门批准人。 - **Brief 不含 DNA 信息**:Content Producer 看不到 main 的 DNA,只按 Brief 制作;也不要把 DNA 文档路径写进 Brief。 -- **口播类视频**:口播文案子模块启用时,口播终稿由 main agent 写好并随 Brief 交付;真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写策略文案。 +- **口播类视频**:口播终稿一律由 main agent 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写);真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写。 - **工作区**:main 不替 CP 建工作区,也不指定项目目录;CP 自建工作区,双方 T3 权限可互访取文件。 ## 对标接口 diff --git a/crews/main/skills/expert-ir/workflows/investor-materials.md b/crews/main/skills/expert-ir/workflows/investor-materials.md index 1adf6e08..1fd7a890 100644 --- a/crews/main/skills/expert-ir/workflows/investor-materials.md +++ b/crews/main/skills/expert-ir/workflows/investor-materials.md @@ -59,7 +59,7 @@ - 现场路演/拜访场景 → 用演示文稿工具生成 PPTX - 用户未指定时,简要介绍两种方式让用户选择 -**配图**:优先使用 `siliconflow-img-gen`(16:9),不可用时尝试 `pexels-footage` 或 `pixabay-footage` +**配图**:优先使用 `awk-img-gen`(16:9),不可用时尝试 `pexels-footage` 或 `pixabay-footage` ### One-Pager / 投资人备忘录 diff --git a/crews/main/skills/expert-wx-channel/SKILL.md b/crews/main/skills/expert-wx-channel/SKILL.md index 959f33d6..a7ea48cb 100644 --- a/crews/main/skills/expert-wx-channel/SKILL.md +++ b/crews/main/skills/expert-wx-channel/SKILL.md @@ -41,11 +41,11 @@ metadata: | `wechat-channels-publish` | 发布视频到视频号创作者中心(camoufox-cli 持久化 session `wechat-channel`) | 无 wrapper,按工具说明驱动 `camoufox-cli` | | `wx-channel-engagement` | 视频号助手后台作品数据抓取,写入 published-track 的 `pub_wx_channel` 表 | `wx-channel-engagement` | -跨领域通用技能:`published-track`(发布记录与指标库)、`content-calibrator`(DNA 表现评估)、`smart-search`(跨平台搜索,选题调研优先社交平台)、`council`(定位决策辅助)、`siliconflow-img-gen`(封面图生成)。 +跨领域通用技能:`published-track`(发布记录与指标库)、`content-calibrator`(DNA 表现评估)、`smart-search`(跨平台搜索,选题调研优先社交平台)、`council`(定位决策辅助)、`awk-img-gen`(封面图生成)。 -素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 +素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门,仅用于 main 自做轻加工成品的自检;CP 成片质检在 CP 流程内完成)、`awk-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 -**视频全案分工硬边界**:main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营,也直接做图文内容与已有素材轻加工;视频全案的成片制作委托 `content-producer`。口播类视频的口播文案由 main 按 `narration-script` 子模块写好并随 Brief 交付(真人口播时,指导用户录音并取得录音文件),CP 不重写策略文案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。Brief **不含 DNA 信息**、**不写实现路径**(参考脚本、引擎参数之类实现手段归 CP,规则详见 Content Production Workflow 的 Brief 硬性规则),main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 +**视频全案分工硬边界**:除非是基于已有素材轻加工,否则视频全案的制作均应委托 `content-producer`。main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营;**口播**(真人出镜 / 数字人 / 真人录音)的口播稿一律由 main 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写;真人口播时,指导用户录音并取得录音文件),CP 不重写;**旁白**(剪辑配的解说)完全由 CP 写,main 不出旁白稿。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 ## 平台速查 diff --git a/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md b/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md index 0aca54fd..783442f7 100644 --- a/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md +++ b/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md @@ -11,8 +11,8 @@ description: 通过 camoufox-cli 持久化 session wechat-channel 发布视频 **业务页 `snapshot` 一律加 `-s "wujie-app"` 作用域**(下文简写 `snapshot -s`):wujie 页面里主文档与子应用各有一个 body,整页 `snapshot` 会报 `locator('body') resolved to 2 elements` strict violation,拿不到任何 ref。只有登录页(无 wujie)可整页 snapshot。`click` / `fill` / `type` 按 ref 操作;`upload` 支持 ref 或 CSS 选择器(底层 Playwright setInputFiles,穿透 shadow DOM),无需 CDP hack。 -**输入**:本地视频文件(`.mp4` / `.mov` / `.avi` / `.webm`)、**视频描述**(含话题标签,最长约 300 字)、**短标题**(6-16 字)。发布页改版后两项都能填,官方明确「填写短标题会获得更多流量」,因此**两项都必须填**,都由 main agent 拟定后交给本工具。 -**输出**:视频号已发布作品;能取到时附带公开链接(`https://weixin.qq.com/sph/xxxx`)。 +**输入**:本地视频文件(`.mp4` / `.mov` / `.avi` / `.webm`)、**视频描述**(含话题标签,约 300 字为建议值——实测 409 字符平台照常接受,以页面输入框实际限制为准)、**短标题**(6-16 字,**不得含标点符号,只能用空格分隔**——见 Step 6 硬约束)。发布页改版后两项都能填,官方明确「填写短标题会获得更多流量」,因此**两项都必须填**,都由 main agent 拟定后交给本工具。 +**输出**:视频号已发布作品。本工具**不抓取作品公开链接**——管理后台分享面板路线实践中不可靠(审核 / 转码中常不可用),链接由用户后补,调用方入库时升级(见文末「入库衔接约束」)。 > **短标题只在发布页存在**:作品管理页与 `wx-channel-engagement` 抓取都拿不到短标题,所以入库与匹配一律只用视频描述(见文末「入库衔接约束」)。 @@ -80,10 +80,12 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil - `编辑 分享卡片 4:3`(朋友圈/聊天分享卡) ``` -1. snapshot -s "wujie-app" 找目标卡片的「编辑」按钮 ref → click - - ref click 弹不出弹窗时,eval 递归匹配文本点击(按卡片名区分两个入口, - 个人主页卡片则把 '分享卡片' 换成 '个人主页卡片'): - camoufox-cli --session wechat-channel --persistent --json eval "(()=>{let t=null;const walk=(r)=>{if(!r||t)return;if(r.nodeType===1){const x=(r.innerText||'').trim();if(x.indexOf('编辑')===0&&x.indexOf('分享卡片')>=0){t=x;r.click();return;}if(r.shadowRoot)walk(r.shadowRoot);}for(const c of r.children||[])walk(c);};walk(document);return t?'clicked':'not found';})()" +1. 优先 eval dispatchEvent 点击目标卡片的「编辑」按钮(编辑按钮是缩略图上的悬浮层 + DIV.edit-btn,snapshot 拿到的 ref click 常点到文本容器上超时),按卡片名区分两个 + 入口(个人主页卡 3:4 / 分享卡 4:3——个人主页卡片入口把 '分享卡片' 换成 '个人主页卡片'): + camoufox-cli --session wechat-channel --persistent --json eval "(()=>{let t=null;const walk=(r)=>{if(!r||t)return;if(r.nodeType===1){const x=(r.innerText||'').trim();if(x.indexOf('编辑')===0&&x.indexOf('分享卡片')>=0){t=x;r.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}));r.dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));r.click();return;}if(r.shadowRoot)walk(r.shadowRoot);}for(const c of r.children||[])walk(c);};walk(document);return t?'clicked':'not found';})()" + - 悬浮层按钮只认完整事件序列:mousedown + mouseup + click 三连 dispatch,单 r.click() 可能不触发 + - eval 返回 not found 或点击无效(弹窗没弹出)时,降级 snapshot -s "wujie-app" 拿「编辑」按钮 ref → click 2. 封面编辑弹窗打开(标题如「编辑分享卡片」,副标题「将会用在朋友圈、聊天等场景」),两种封面来源: - 从视频中选择封面:一排视频帧缩略图(胶片条),click 选一帧 - 上传封面:先点「上传封面」入口(`+` 号方框按钮;ref click,或 eval 递归匹配 @@ -107,7 +109,7 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil camoufox-cli --session wechat-channel --persistent --json eval "(()=>{let t=null;const walk=(r)=>{if(!r||t)return;if(r.nodeType===1){if(r.getAttribute&&r.getAttribute('data-placeholder')==='添加描述'){t=r;return;}if(r.shadowRoot)walk(r.shadowRoot);}for(const c of r.children||[])walk(c);};walk(document);if(!t)return 'not found';t.scrollIntoView({block:'center'});t.focus();t.click();return 'focused';})()" - 返回 focused 才能继续;not found → 等 2 秒 wujie 初始化后重试 - ❌ 不要按 contenteditable='true' 过滤——该属性是空串,按 data-placeholder 定位 -2. 插正文(execCommand 在当前焦点处插入,真实触发 input 事件;话题标签直接写在描述中,最长约 300 字): +2. 插正文(execCommand 在当前焦点处插入,真实触发 input 事件;话题标签直接写在描述中;约 300 字为建议值,不因字数疑虑截断文案——以页面输入框实际限制为准,插入后第 4 步 eval 校验长度): camoufox-cli --session wechat-channel --persistent --json eval "(()=>{const ok=document.execCommand('insertText',false,'<正文>');return ok?'inserted':'execCommand failed';})()" 3. 换行 + 逐段插署名/话题标签(insertParagraph 与 insertText 交替): camoufox-cli --session wechat-channel --persistent --json eval "(()=>{document.execCommand('insertParagraph');document.execCommand('insertText',false,'<署名段>');document.execCommand('insertParagraph');document.execCommand('insertParagraph');document.execCommand('insertText',false,'<#话题标签段>');return 'done';})()" @@ -118,6 +120,13 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil - 校验不过(缺段/串位):重跑 1 聚焦后 execCommand('selectAll') + execCommand('delete') 清空,再从 2 重插 ``` +> **话题标签段格式(硬约束)**:标签段只能是半角 `#` + 空格分隔的 `#标签` 项,整行不得出现任何其他字符——不加前缀或分组标题(如「【三层标签】」)、不加分隔符(/、/、—、顿号)、不用全角 `#`。混入这些字符会导致**整行标签全部不被视频号识别渲染(失效)**。DNA 的分层/分组标签策略只用于挑标签,结构标记不进正文。 +> +> - ✅ `#AI员工 #开源工具 #影视解说 #Wiseflow #一人成团 #Agent327` +> - ❌ `【三层标签】#影视解说 #反转 #悬疑短片 / #AI商机监控 #AI客服 #自媒体获客 / #遗憾 #十年` +> +> 定稿文案已含违规字符时,先清洗成纯标签行再插入,不要照插。 + **短标题**——真实 ``(placeholder 含「短标题」,官方提示形如「填写短标题有机会获得更多流量」),aria snapshot 有独立 ref,直接填: ``` @@ -130,6 +139,13 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil - value 与短标题一致 → 过;为空 → 重新 fill 再校验 ``` +> **短标题不得含标点符号(硬约束)**:短标题里不能出现任何标点(逗号、顿号、问号、感叹号、冒号、引号等),分隔只能用空格——该字段按纯文本处理,标点会被平台拒掉或截断。 +> +> - ✅ `兔子睁眼 恶霸慌了` +> - ❌ `兔子睁眼,恶霸慌了`(逗号去掉后直接填会变 `兔子睁眼恶霸慌了` 短语粘连——标点要**替换成空格**,不是删除) +> +> 定稿文案已含标点时,先清洗(标点→空格、连续空格压成一个)再 fill,不要照填。 + > 改版后发布页字段名与提示文案可能微调:**以 snapshot -s 读到的实际 placeholder / 标签文本为准**定位,不要写死选择器。找不到短标题字段时(页面回滚或灰度未开),只填视频描述并在回报里注明「短标题字段未出现」,不要把它塞进描述。 ### Step 7: 发布 @@ -148,24 +164,11 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil 等待 4 秒后检查(`url` 命令 + `snapshot -s "wujie-app"`): - 页面自动跳转到视频管理列表页 - 或 URL 变为 `https://channels.weixin.qq.com/platform/post/list` -- 刚发表的作品通常在第一个。但可能处于转码中——封面缩略图为灰色,转圈。每隔 5 秒 snapshot -s 看转码是否完成(封面缩略图出现),完成后才能取链接。 - -### Step 9: 获取已发布视频链接 - -发布成功后,在视频号管理后台的视频列表页获取视频公开链接: - -``` -1. snapshot -s "wujie-app" 找到刚发布的视频(列表第一条,或按完整视频描述匹配)ref -2. snapshot -s 找该视频的"分享"按钮 ref → click -3. snapshot -s 在弹出的分享面板中找"复制视频链接"按钮 ref → click -4. eval 从剪贴板读取链接: - camoufox-cli --session wechat-channel --persistent --json eval "navigator.clipboard.readText()" - 链接格式通常为 https://weixin.qq.com/sph/xxxxxx(sph 即视频号拼音缩写) -``` +- 刚发表的作品通常在第一个;处于转码中(封面缩略图灰色、转圈)属正常,不影响发布成功判定,**不必等转码完成**。 > 管理页若 `-s "wujie-app"` 报作用域不存在/为空(该页未走 wujie 容器),退回整页 snapshot。 -> **注意**:如果刚发布的视频还在审核中,"分享"按钮可能不可用。此时可先完成发布记录(publish_url 留空),待审核通过后再补充链接。 +确认发布成功后 close session 并回报。**不要尝试在管理后台抓取作品链接**——分享面板路线实践中不可靠(审核 / 转码中常不可用),已从本流程移除。回报时告知用户作品已提交,请其后续在手机端或后台「分享」中获取作品链接(`https://weixin.qq.com/sph/xxxx`)提供给我们,由调用方按「入库衔接约束」补录升级。 --- @@ -245,6 +248,12 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil - **症状**:跳转到扫码登录页,无用户名/密码选项 - **workaround**:走前置条件的无头截图扫码流程(screenshot QR PNG → 发用户扫码 → 轮询 URL 确认登录就位) +### pitfall: short_title_punctuation_rejected + +- **触发**:短标题文案含标点(逗号、顿号、问号、感叹号等) +- **症状**:平台拒掉或截断短标题,或发布后短标题显示异常 +- **workaround**:短标题只能是「文字 + 空格」——标点**替换成空格**(不是删除,删了短语会粘连),连续空格压成一个再 fill + ### pitfall: short_title_field_missing - **触发**:发布页填写短标题时 @@ -279,6 +288,8 @@ camoufox-cli --session wechat-channel --persistent --json upload "input[type=fil 本工具只管发布到视频号后台,**不做发布记录入库**;入库由 Content Production Workflow 编排(调 `published-track record`)。调用方必须注意: -> **`published-track record --platform wx_channel --title` 必须传 Step 6 填的完整视频描述**(含 hashtag,最长约 300 字);**短标题不入库**。 +> **`published-track record --platform wx_channel --title` 必须传 Step 6 填的完整视频描述**(含 hashtag,约 300 字为建议值,实际以页面输入框为准);**短标题不入库**。 原因:作品管理页只展示视频描述,`wx-channel-engagement` 抓取匹配用的也是视频描述。`pub_wx_channel.title` 是数据库字段名,语义为完整视频描述;把短标题写进去会导致后续抓取匹配失败。短标题只留在作品目录的 `publish-copy.md` 里备查。 + +> **首次入库不传 `--publish-url`**:本工具不抓取作品链接,链接由用户后补(同 `wx-mp-publisher` 的 URL 升级机制)。用户提供链接后,用相同 `--source-folder` 重跑 `published-track record` 补 `--publish-url`——upsert 语义升级记录,不重复插行。`wx-channel-engagement` 抓取不依赖 `publish_url`,留空不影响日常数据采集。 diff --git a/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/references/video-dna-framework.md b/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/references/video-dna-framework.md index 69162ac8..c92692a7 100644 --- a/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/references/video-dna-framework.md +++ b/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/references/video-dna-framework.md @@ -22,7 +22,7 @@ DNA 文档 -> DNA template |--------|----------| | 选题与观看理由、标题与封面写法、内容创意原型、业务植入套路、互动引导与 CTA 套路、视频内容形态与制作指向、制作规格与视听倾向、(口播类)口播文案子DNA、(对标账号)账号运营子模块 | 创作细节、脚本结构、逐句台词、镜头表、转场与编码参数——那些归 Content Producer | -视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与(口播类的)**口播文案**。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 +视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与**口播**(真人出镜 / 数字人)的**口播文案**;旁白(剪辑配的解说)由 CP 写,不在 DNA 指导范围。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 ## 维度(10 维) @@ -101,14 +101,14 @@ DNA template = **Brief.md 正文模板 + 口播文案模板(可选)**,固 | DNA 观测到的内容形态 | 制作指向(只能写真实存在的资源名) | Brief `workflow` 字段值 | |---------------------|-----------------------------------|------------------------| | 影视解说 / 剧情解说 + 反转植入(「万万没想到」式) | Content Producer `expert-video` → **Reversal Ad** workflow | `reversal-ad` | -| 口播类(真人口播出镜,或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | +| 口播 / 旁白类(真人口播或数字人出镜;或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | | 一句文稿转视觉隐喻的纸拼贴动画 | Content Producer `expert-video` → **Collage B-roll** workflow | `collage-broll` | -| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,Stage 1 定档位(narrative / motion / montage) | 省略 | +| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,据创意自定叙事 / 动效 / 蒙太奇手法 | 省略 | | 已有素材简单拼接、加旁白、烧字幕 | main `video-edit`(不委托 CP) | — | | 已有真人口播素材去口气词、剪高光 | main `talking-head-cut`(不委托 CP) | — | | 产品操作录屏 | main `ui-demo`(不委托 CP) | — | -Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,档位由 Stage 1 `intent-router` 定。 +Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。 ## Focus ID 表 diff --git a/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/scripts/build_style_profile.py b/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/scripts/build_style_profile.py index bab59fca..39f0fd93 100644 --- a/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/scripts/build_style_profile.py +++ b/crews/main/skills/expert-wx-channel/tools/wx-channel-style-profiler/scripts/build_style_profile.py @@ -88,7 +88,7 @@ "topic-angle": "- 单篇观测:选题类型、选题入口(现象 / 问题 / 冲突 / 数据 / 热点 / 挑战 / 个人经历)、目标人群为什么要看完(理解 / 判断 / 行动 / 避坑 / 身份认同 / 情绪共鸣)。\n- 边界:只记本篇,不判断跨篇稳定性;不评价选题好坏。", "title-cover": "- 单篇观测:**短标题**(发布页可填,官方称填了能获得更多流量;作品管理页不展示,取数时拿不到)与**视频描述**(含话题标签)两者的原文与写法模式。\n- 取数边界:抓取到的作品只有视频描述;短标题拿不到时写「未观测(管理页不展示)」,不得把描述当短标题。\n- 视觉证据:封面或首帧必须由视觉模型读取图片,至少提取画面主体与场景、构图与画幅、色彩体系、光线与质感、风格与媒介、文字视觉、品牌识别元素、避免项,并反推为可执行的 AIGC 提示词要素;无图片写「未提供」。", "content-idea": "- 单篇观测:一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点。\n- 可复用信号:这个创意套路换成别的主题还能怎么用。\n- 边界:只记创意层,不记创作细节(逐句台词、镜头表、脚本结构、转场与编码参数)。", - "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程 + Stage 1 定档位),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", + "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程据创意自定手法),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", "production-spec": "- 单篇观测:横屏或竖屏、时长带、画面风格(色调、质感、字幕样式倾向、信息密度)、配音音色与声音形态(原声口播 / TTS / 旁白 / 纯画面字幕)、BGM 与音效倾向、封面规格。\n- 边界:只记规格与倾向,不规定镜头参数、逐镜设计、转场与编码细节——那些归 Content Producer。", "biz-implant": "- 单篇观测:是否有业务植入(纯内容 / 软植入 / 硬广直给)、植入位置与时机、植入载体(剧情道具 / 口播一句话 / 字幕卡片 / 场景背景 / 案例与数据 / 清单第 N 项 / 教程步骤内嵌 / 产品截图 / 购物车或留资组件 / 主页与私信引导)、植入方式原型(反转植入 / 痛点→方案 / 场景带入 / 实测对比 / 身份认同 / 口碑故事 / 教程内嵌 / 硬广直给)、内容与业务的衔接句(原文摘录)、植入密度与占比、品牌词与产品名出现方式与频次。\n- 证据要求:位置 + 载体 + 原文摘录三样齐全;本篇无植入时写「无植入(纯内容)」,不得留空。\n- 边界:只记植入套路,不写逐句广告文案;形态层面的「影视解说 + 反转植入」由 `video-form` 记形态与制作指向。", "interaction-cta": "- 单篇观测:行动目标(关注 / 评论 / 收藏 / 转发 / 私信 / 主页点击 / 进店 / 咨询 / 搜索品牌词 / 购物车 / 直播预约)与本篇主目标、CTA 出现位置与时机(口播收尾句 / 字幕卡 / 片尾贴片 / 描述区 / 正文结尾 / 图组末图 / 评论区)、CTA 句式与原文摘录(命令式 / 提问式 / 利益式 / 身份式 / 悬念式)、一篇放几个行动、诱因设计(利益点 / 情绪 / 身份认同 / 稀缺)、与业务转化目标的对应。\n- 合规边界:不隐藏站外联系方式、不绕平台检测、不以利益换互动,记为必须避免项。", @@ -120,7 +120,7 @@ } TEMPLATE_CHECKLISTS = { - "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 短标题与视频描述是否都已拟定(视频号两者都必须填),且写法来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类是否附口播文案(或真人口播录音路径);口播子模块未启用时是否避免规定逐句口播。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", + "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 短标题与视频描述是否都已拟定(视频号两者都必须填),且写法来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类(真人出镜 / 数字人 / 真人录音)是否附口播终稿(`voiceover.md` 绝对路径)或真人录音路径——无论口播子模块是否启用;剪辑配解说的旁白归 CP 写,Brief 口播字段写「不适用」。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", } DNA_SUBMODULE_NOTE = "- **口播文案子模块**(`narration-script`):仅口播类视频启用,用于指导 main agent 写同类型视频的口播文案;它不是独立 DNA,未启用时写「未启用」。\n- **账号运营子模块**(`account-bio`、`content-mix-cadence`):只在样本来自用户提供的对标账号(可从账号发布列表批量提取)时填写;结论只写进本 DNA 文档,不进 template;样本不足写「未观测」。" diff --git a/crews/main/skills/expert-wx-channel/workflows/content-production.md b/crews/main/skills/expert-wx-channel/workflows/content-production.md index 6fb5f631..60652e1a 100644 --- a/crews/main/skills/expert-wx-channel/workflows/content-production.md +++ b/crews/main/skills/expert-wx-channel/workflows/content-production.md @@ -20,8 +20,8 @@ | 路线 | 判断 | 执行方 | | --- | --- | --- | | 素材组装 / 轻剪辑 | 用户手里有可用素材 | main 直接做:`video-edit` / `talking-head-cut` / `ui-demo` | -| 从零制作 | 没有素材,需要出脚本、拍摄 / 生成画面 | main 出制作简报,委托 `content-producer` | -| 脚本制作 | 用户已有脚本 | 按 DNA 对脚本做必要修改,提交用户确认后,脚本交 `content-producer` 制作 | +| 从零制作 | 没有素材,需要出脚本、拍摄 / 生成画面 | main 出 Brief,委托 `content-producer` | +| 脚本制作 | 用户已有脚本 | 用户脚本按素材处理(绝对路径写进 Brief 素材清单,必须保留的事实 / 结构要点写进 Brief 要求);main 只调策略层(选题 / 口播口径 / 植入 / CTA),不改写脚本本体、不动分镜与画面执行——需动分镜即改走 Brief 委托,由 CP 重出 | ### 3. 参考视频的意图判断 @@ -164,8 +164,8 @@ DNA 约束的是选题与观看理由、短标题与视频描述写法、内容 视频号发布页改版后可同时填 **视频描述** 与 **短标题**,官方明确「填写短标题会获得更多流量」,因此**两项都必须有、发布时都必须填**,且都由 main agent 拟定。 -1. **短标题**:按 DNA template 的「短标题模式」写,6-16 字,承担身份信号与价值预告;给 2-3 个候选。 -2. **视频描述**(≤300 字,含 2-5 个话题标签,标签直接写在描述中):按 DNA template 的「视频描述结构」写;给 2-3 个候选,说明各自的观看理由与标签策略。 +1. **短标题**:按 DNA template 的「短标题模式」写,6-16 字,承担身份信号与价值预告;给 2-3 个候选。**短标题中不能含标点符号,只能用空格分隔**(逗号、顿号、问号、感叹号等一律不出现——发布页对该字段按纯文本处理,标点会被平台拒掉或截断)。候选与定稿都必须满足;定稿后发现含标点时,把标点替换成空格再发布(直接删会让短语粘连)。 +2. **视频描述**(约 300 字为建议值——实测 409 字符平台照常接受,以页面输入框实际限制为准,插入后以 eval 校验为准;含 2-5 个话题标签,标签直接写在描述中):按 DNA template 的「视频描述结构」写;给 2-3 个候选,说明各自的观看理由与标签策略。**标签段只能是半角 `#` + 空格分隔的 `#标签` 项**(如 `#AI员工 #开源工具 #影视解说`),不得带分组前缀(「【三层标签】」之类)、分隔符(/ 等)或全角 `#`——否则整行标签不被视频号识别、全部失效;DNA 的分层标签策略只用于挑标签,结构标记不进正文(硬约束详见 `wechat-channels-publish` Step 6)。 3. 有参考视频或用户文案时,必须参考其文案内容(如有),但不得照抄。 4. 用户已指定文案或原则时,候选必须在该约束内生成,不得偷换方向。 5. 描述可提及产品或业务,但不放明显引流信息;禁止二维码、联系方式与谐音绕检测,按 DNA 的 `interaction-cta` 只保留一个主行动引导。 @@ -193,22 +193,22 @@ DNA 约束的是选题与观看理由、短标题与视频描述写法、内容 ### 路线 B / C:委托 content-producer 制作 -1. 产出**制作简报** `wx_channel/outputs//brief.md`(Brief 是 main / CP 的唯一交接物): +1. 产出 **Brief** `wx_channel/outputs//brief.md`(Brief 是 main / CP 的唯一交接物): ```markdown # 视频号视频制作 Brief - 视频名 / slug: - platform:wx_channel -- workflow:reversal-ad / narration-video / collage-broll / 未指定(未指定 = CP 按其通用制作流程做,Stage 1 自定档位) +- workflow:reversal-ad / narration-video / collage-broll(视频形态未确定时省略本字段;省略 = CP 按其通用制作流程做,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定) - 选题与观看理由: - 核心传达: - 内容创意:创意原型 + 展开逻辑 + 记忆点(+ 反转设计,如为反转植入类) - 业务植入与 CTA:植入位置与方式原型 + 内容与业务的衔接句要求 + CTA 主目标与句式 - 封面要求:封面主文案(用短标题或核心传达)+ 视觉方向 - 制作规格:横屏 / 竖屏、时长带、画面风格、配音音色与声音形态、BGM 与音效、字幕 -- 口播文案:`voiceover.md` 绝对路径(口播类必填)/ 真人口播录音绝对路径 / 不适用 -- 素材清单:逐条**绝对路径** + 来源 + 授权(无素材时写「无,由 CP 按 Brief 取材」) +- 口播文案:`voiceover.md` 绝对路径 / 真人口播录音绝对路径 / 不适用(口播 = 真人出镜、数字人、真人录音;剪辑配解说的**旁白归 CP 写**,写「不适用」) +- 素材清单:逐条**绝对路径** + 来源 + 授权 + 规格(分辨率 / 帧率 / 时长——多源帧率不一致需 CP 在拼接时统一,注明各源帧率让 CP 规划期即预判最低公共规格)(无素材时写「无,由 CP 按 Brief 取材」) - 交付物与验收:`video.mp4` + `cover.jpg` + `final-deliver.md`,回报三者绝对路径;验收标准 - 闸门:GATE A / GATE B 批准人(用户或 main 代理批准 + 批准范围) - 禁止事项:事实与承诺边界、合规红线、禁用方向 @@ -223,9 +223,9 @@ Brief 硬性规则: - **不写实现路径**:Brief 只写需求与验收标准(「植入段必须动态化、禁静态贴图」「字幕逐句对齐不飘」及分辨率/帧率/时长带等交付规格),不写实现手段——❌「参考/照改某脚本」、❌ ffmpeg/引擎参数表(CRF、threads、nice、MarginV 等)、❌ 工具链内部细节;用什么工具、什么参数达成需求归 CP。例外:用户点名的工程参数照传并注明「用户指定」。发现需求必须指定脚本/参数才说得清时,改写成可观察的验收结果——那是需求没写清,不是越界的许可。 - **甲乙方关系**:需求方向、品牌事实、发布文案归 main;制作方案、分镜、渲染参数归 CP。 -2. 口播类视频:按 DNA 的 `narration-script` 子模块写口播终稿 `wx_channel/outputs//voiceover.md`,Brief 里给绝对路径;真人口播时指导用户按口播稿录音,完成后向用户取得录音文件。口播子模块未启用时 Brief 写「口播文案:不适用」或只给要点,由 CP 按其 workflow 组织旁白,main 不再规定逐句台词。用户必用的事实、案例、承诺和 CTA 必须进入 Brief 或口播终稿,不得为了形式删除关键事实。 +2. 口播(真人出镜 / 数字人 / 真人录音)视频:口播稿一律由 main 写——`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写——落 `wx_channel/outputs//voiceover.md`,Brief 里给绝对路径;真人口播时指导用户按口播稿录音,完成后向用户取得录音文件。剪辑配解说的旁白归 CP 写,main 不出旁白稿;「不适用」即旁白类或非口播视频。用户必用的事实、案例、承诺和 CTA 必须进入 Brief 或口播终稿,不得为了形式删除关键事实。 3. 参考模式下,把选题与创意结论写进 Brief 的「内容创意」段即可;`viral-chaser` 拆解报告是 main 的采样材料,**不作为 Brief 附件交给 CP**。 -4. spawn `content-producer` 委托制作:只交 Brief + 素材绝对路径 + 口播文案 / 录音;不指定 CP 的工作区与制作方案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。 +4. spawn `content-producer` 委托制作:只交 Brief 一份——素材、口播文案 / 录音均已以绝对路径写在 Brief 内;不指定 CP 的工作区与制作方案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),叙事 / 动效 / 蒙太奇手法由 CP 据创意自定;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。 5. Brief 变更时更新版本并推送变更要点;已开工中间产物按新版取舍,弃用部分记入交付说明。制作中发现 Brief 无法执行(素材缺失、时长超标)时,由 CP 回报、main 与用户确认后改 Brief,CP 不擅自改策略。 6. CP 交付后,按其回报的绝对路径把成片与封面取回 `wx_channel/outputs//`(`video.mp4` / `cover.jpg`),并把交付说明要点记入作品目录。用户直接提供成片时校验格式(`.mp4` / `.mov` / `.avi` / `.webm`)与时长后复制进作品目录。 @@ -235,20 +235,35 @@ main 负责监督并推动 CP 的进度,卡住时追问而不是替它做。 第三个必停节点。成片与封面确认后才可发布。用户有意见按意见修改,直至确认。 +修改委托回 CP 时不用临时写任务描述,一律用**成片修改单**格式: + +```markdown +# 成片修改单(vN → vN+1) +- v1 成片路径:<绝对路径> +- 修改点:逐条列出(注明每条改动落在哪个段 / 哪个时间点) +- 不动范围:明确不动哪些(时间轴 / 音轨 / 字幕 / 未涉及段的画面) +- 验收标准:逐条可观察的结果 +- 回滚要求:v1 成片保留为 `artifacts/video_v1_backup.mp4`,v1/v2 并存不覆盖 +``` + +CP 交付说明会相应增补「修改记录」段(其技能约定,见 expert-video「改片(定向修改)约定」)。 + ## Step 6 - 发布 按 `wechat-channels-publish` 工具说明驱动 camoufox-cli 发布(该工具无 wrapper,逐条按其步骤执行): +0. **封面变体准备(标准步骤,发布前完成)**:视频号发布页封面是双入口——个人主页卡 3:4、分享卡 4:3,直接裁 16:9 主图会切掉封面标题。CP 交付的 `cover.jpg` 是 16:9 主图,两个变体由 main 在发布前生成,采用**模糊底嵌入法**(实测参数):目标画布 3:4 为 1080×1440、4:3 为 1440×1080;底图 = `cover.jpg` 放大铺满画布 + 高斯模糊 + 亮度压到 0.55;前景 = `cover.jpg` 全宽居中嵌入。产物落作品目录 `cover_profile_3x4.jpg` / `cover_share_4x3.jpg`,发布时两个封面入口分别上传对应变体(各入口的裁剪框里居中构图即可,标题不会被裁掉)。 1. 打开创作者中心发布页,检查 `wechat-channel` session 登录态;失效时走该工具的无头截图扫码登录流,**Stop and wait** 等用户扫码确认。 2. 上传 `video.mp4`,等待上传与转码完成(最长 3 分钟)。 -3. **设置封面**:作品目录有定稿 `cover.jpg` 时按工具说明「Step 5: 设置封面」上传(用户明确要用视频帧时在弹窗胶片条选帧代替)。 +3. **设置封面**:两个封面入口分别上传 Step 6.0 生成的 `cover_profile_3x4.jpg`(个人主页卡)与 `cover_share_4x3.jpg`(分享卡),按工具说明「Step 5: 设置封面」执行;变体缺失时先用模糊底嵌入法补齐再上传,不要拿 16:9 主图直接对两个入口硬裁(会切标题)。(用户明确要用视频帧时在弹窗胶片条选帧代替。) 4. **视频描述与短标题都要填**(均取 Step 4 定稿):视频描述含话题标签,短标题按发布页字段单独填;两项都不得留空。 5. 点击“发表”;弹出原创声明弹窗时按工具说明处理。发布后用户会在手机端补充原创声明等事项。 -6. 发布成功后在视频列表页等转码完成,通过分享面板复制视频链接(`https://weixin.qq.com/sph/xxxx`);审核中拿不到链接时留空,后续补充。 -7. 遇到「session wechat-channel 正忙」(fail-first 队列)时等当前操作完成再重试,不盲试、不 close。 +6. 遇到「session wechat-channel 正忙」(fail-first 队列)时等当前操作完成再重试,不盲试、不 close。 必须等待发布流程完整返回后再判定结果,禁止提前自行判断发布成功。用户要求人工检查后再发布时,按该工具「手动模式」执行。 +**作品链接不抓取、由用户后补**:管理后台分享面板复制链接的路线实践中不可靠(审核 / 转码中常不可用),发布后不去后台取链接。发布后告知用户作品已提交,请其后续在手机端或后台「分享」中获取作品链接(`https://weixin.qq.com/sph/xxxx`)提供给我们,再按 Step 7 补录升级。 + ## Step 7 - 记录 1. 定稿作品目录 `wx_channel/outputs//`: @@ -257,10 +272,10 @@ main 负责监督并推动 CP 的进度,卡住时追问而不是替它做。 - `voiceover.md`(口播类:main 写的口播终稿) - `publish-copy.md`(短标题 + 视频描述定稿,发布与入库都以它为准) - `video.mp4` 成片(或 `.mov` / `.avi` / `.webm`) - - `cover.jpg` 封面图 + - `cover.jpg` 封面图(16:9 主图)+ `cover_profile_3x4.jpg` / `cover_share_4x3.jpg`(Step 6.0 发布前生成的封面变体) - `materials/` 原始素材(Step 2 已放入) -2. 写 `wx_channel/outputs//dna-meta.json`,记录本条所用 DNA: +2. 写 `wx_channel/outputs//dna-meta.json`,记录本条所用 DNA。**这是取回成片后的显式动作,不要靠自觉想起**——CP 不建 main 侧目录,漏写它 record 入库的 `dna_id` 就是空: ```json {"platform": "wx_channel", "dna_id": ""} @@ -273,13 +288,13 @@ published-track record \ --platform wx_channel \ --source-folder wx_channel/outputs// \ --account <发布所用账号 alias> \ - --title "" \ - --publish-url "" + --title "" ``` - `--title` 是数据库字段名,**必须传完整视频描述**(含 hashtag)——`wx-channel-engagement` 抓取按它匹配后台作品。 - **短标题不入库**:管理页不展示短标题,写库会造成匹配混淆;短标题只留在作品目录的 `publish-copy.md`。 - `dna_id` 自动从 `dna-meta.json` 读取。 - - 发布时没拿到链接的,后续用户提供或抓取到链接后用相同 `--source-folder` 重跑 `published-track record` 补 `--publish-url`——upsert 语义升级记录,不重复插行。 + - **首次入库不传 `--publish-url`**(发布流程不抓链接,见 Step 6)。后续用户提供作品链接时,用相同 `--source-folder` 重跑 `published-track record` 补 `--publish-url`——upsert 语义升级记录,不重复插行(同公众号 URL 升级机制)。 + - **record 后核对 `dna_id` 非空**(`published-track query` 或入库返回确认):为空说明 `dna-meta.json` 漏写或格式错——补写后用相同 `--source-folder` 重跑 record 升级记录,不留空值过夜。 4. 发布流程到此结束;互动数据由每日定时采集任务统一抓取,复盘走 `review.md`。 diff --git a/crews/main/skills/expert-wx-channel/workflows/editing.md b/crews/main/skills/expert-wx-channel/workflows/editing.md index d0305f39..108f2fb5 100644 --- a/crews/main/skills/expert-wx-channel/workflows/editing.md +++ b/crews/main/skills/expert-wx-channel/workflows/editing.md @@ -49,7 +49,7 @@ ## 换封面 1. 读目标 DNA 的 `title-cover` 与 `production-spec`,保留封面三要素:身份 + 痛点 + 解决方案。 -2. 按新主题出 2-3 个候选:素材截帧优先,无合适素材走 `siliconflow-img-gen` 生成。 +2. 按新主题出 2-3 个候选:素材截帧优先,无合适素材走 `awk-img-gen` 生成。 3. 用户确认后替换 `wx_channel/outputs//cover.jpg`。 ## 压缩 / 展开时长 @@ -67,7 +67,7 @@ ## 改后发布 -用户要求改完后发布 / 重新发布时,走 `content-production.md` 的成片后流程(Step 5-7): +用户要求改完后发布 / 重新发布时,走 `content-production.md` 的改后制作与发布流程(Step 5-7): 1. **成片同步**:脚本改动涉及画面或口播的,按 `content-production.md` Step 5 重新走视频制作(已有成片只需轻剪辑时走 `talking-head-cut` / `video-edit`)。 2. **存文件**:稿件与成片归位 `wx_channel/outputs//`,同步更新 `brief.md`、`voiceover.md`(如有)、`cover.jpg` 与 `dna-meta.json`。 diff --git a/crews/main/skills/expert-wx-channel/workflows/style-dna.md b/crews/main/skills/expert-wx-channel/workflows/style-dna.md index 952d40a4..0c935adc 100644 --- a/crews/main/skills/expert-wx-channel/workflows/style-dna.md +++ b/crews/main/skills/expert-wx-channel/workflows/style-dna.md @@ -145,7 +145,7 @@ wx-channel-style-profiler update \ - **视频全案**:读取 DNA 文档与 template,main agent 产出 **Brief**(+ 口播类的口播文案)。Brief 写明选题与观看理由、内容创意、`workflow`(视频形态的制作指向)、制作规格、素材清单与授权(绝对路径)、验收标准、闸门批准人。 - **短标题与视频描述由 main agent 拟定**:视频号发布页两项都可填,官方称填短标题能获得更多流量,因此两项都必须有、发布时都必须填;但入库(`published-track`)与 `wx-channel-engagement` 匹配只用**视频描述**,短标题不写库。这两项属发布文案,不进 Brief 的制作要求。 - **Brief 不含 DNA 信息**:Content Producer 看不到 main 的 DNA,只按 Brief 制作;也不要把 DNA 文档路径写进 Brief。 -- **口播类视频**:口播文案子模块启用时,口播终稿由 main agent 写好并随 Brief 交付;真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写策略文案。 +- **口播类视频**:口播终稿一律由 main agent 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写);真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写。 - **工作区**:main 不替 CP 建工作区,也不指定项目目录;CP 自建工作区,双方 T3 权限可互访取文件。 ## 对标接口 @@ -157,4 +157,4 @@ wx-channel-style-profiler update \ - 一个生产任务只使用一个 DNA,且作品类型与任务一致;需要融合时先更新 DNA。 - 样本、用户输入、数据反馈必须可追溯。 - 账号运营子模块(简介写法、内容形式比例、发布习惯)只在对标账号批量样本下填写,且只进 DNA 文档不进 template;覆盖度不足就写未观测。 -- Template 只写 main agent 可执行的规则:视频类 = Brief 正文模板 + 口播文案模板;图文类 = 图文写作模板。都不写成片制作细节。 +- Template 只写 main agent 可执行的规则:视频类 = Brief 正文模板 + 口播文案模板。都不写成片制作细节。 diff --git a/crews/main/skills/expert-wx-mp/workflows/content-production.md b/crews/main/skills/expert-wx-mp/workflows/content-production.md index d227f958..fb24324e 100644 --- a/crews/main/skills/expert-wx-mp/workflows/content-production.md +++ b/crews/main/skills/expert-wx-mp/workflows/content-production.md @@ -169,7 +169,7 @@ DNA 约束的是选题角度、标题、结构、句式、语气和表达方式 1. 读取 DNA template 的封面图风格和封面 AIGC 提示词要素。 2. 结合最终标题、目标读者和本文核心收益,补齐本次封面的主体、场景、构图、色彩、光线、质感、文字视觉和负向约束。 -3. 使用 `siliconflow-img-gen` 生成封面图。 +3. 使用 `awk-img-gen` 生成封面图。 4. 用户已提供封面或封面素材时,可以基于用户已提供的封面或封面素材做编辑,补充必要要素。如果用户提供的不足以成为封面图,比如说尺寸偏小,元素不全等,则作为生成的参考。如果用户明确说:就按照他提供的,那就直接使用。 5. 生成的封面图发给用户确认,确认后保存到 `wx_mp/outputs//cover.jpg`。 diff --git a/crews/main/skills/expert-xhs/SKILL.md b/crews/main/skills/expert-xhs/SKILL.md index b3092feb..bc05573c 100644 --- a/crews/main/skills/expert-xhs/SKILL.md +++ b/crews/main/skills/expert-xhs/SKILL.md @@ -50,7 +50,7 @@ DNA 存储目录是 `xhs/dna/`。未指定 DNA 时默认使用并更新 `dna-0` DNA 是**从一批作品样本提取并聚合出的内容生产规则集**:图文 10 维——选题与观看理由、标题与封面图组、内容创意、**匹配的用户问题**、正文表达与语气、图组视觉、**业务植入套路**、**互动引导与 CTA 套路**,加账号运营子模块(简介写法、内容形式比例、发布习惯);视频 11 维——前四项加**业务植入套路**、**互动引导与 CTA 套路**、视频内容形态与制作指向、制作规格与视听倾向、口播文案子模块与账号运营子模块。搜索维度是小红书必备:最大流量池来自搜索,关键词必须落到用户可能的提问原句。DNA 指导 main agent 写图文或出视频 Brief(+ 口播文案),不规定创作细节与成片制作。维度框架 v2 位于 `xhs-style-profiler` 的 `references/note-dna-framework.md` 与 `references/video-dna-framework.md`。 -**视频全案分工硬边界**:main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营,也直接做图文内容与已有素材轻加工;视频全案的成片制作委托 `content-producer`。口播类视频的口播文案由 main 按 `narration-script` 子模块写好并随 Brief 交付(真人口播时,指导用户录音并取得录音文件),CP 不重写策略文案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 +**视频全案分工硬边界**:除非是基于已有素材轻加工,否则视频全案的制作均应委托 `content-producer`。main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营;**口播**(真人出镜 / 数字人 / 真人录音)的口播稿一律由 main 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写;真人口播时,指导用户录音并取得录音文件),CP 不重写;**旁白**(剪辑配的解说)完全由 CP 写,main 不出旁白稿。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 ## 数据与记录 diff --git a/crews/main/skills/expert-xhs/tools/xhs-content-ops/SKILL.md b/crews/main/skills/expert-xhs/tools/xhs-content-ops/SKILL.md index 233383a2..20d61861 100644 --- a/crews/main/skills/expert-xhs/tools/xhs-content-ops/SKILL.md +++ b/crews/main/skills/expert-xhs/tools/xhs-content-ops/SKILL.md @@ -9,7 +9,7 @@ description: 下载小红书图文笔记(正文 / 图片 / 作者 / 互动数 **用途**:按 URL 或 note-id 下载单篇小红书图文笔记的正文、图片、作者与互动数据(点赞/收藏/评论/分享),供对标分析、DNA 采样、仿写参考使用。 -**输入**:笔记 URL(`xhslink.com` 短链或 `xiaohongshu.com/explore/...` 完整链接),或 `note-id` + `xsec-token`;外加输出目录。 +**输入**:笔记 URL(`xhslink.com` / `xhslink.cn` 短链或 `xiaohongshu.com/explore/...` 完整链接),或 `note-id` + `xsec-token`;外加输出目录。 **输出**:stdout JSON(正文 / 图片列表 / 作者 / stats)+ 图片落盘到输出目录。 **边界**:只处理图文笔记。视频笔记(`noteType: "video"`)返回 `VIDEO_NOTE` 错误,转 `viral-chaser` 处理。 @@ -33,7 +33,7 @@ description: 下载小红书图文笔记(正文 / 图片 / 作者 / 互动数 通过 PATH 调用 wrapper:`xhs-content-ops <参数>`,无需手动拼接 node 命令或脚本路径。 ```bash -# 推荐:直接传 URL(支持 xhslink.com 短链和完整 explore 链接,脚本自动解析 note_id + xsec_token) +# 推荐:直接传 URL(支持 xhslink.com / xhslink.cn 短链和完整 explore 链接,脚本自动解析 note_id + xsec_token) xhs-content-ops \ --url \ --output-dir @@ -50,7 +50,7 @@ xhs-content-ops \ | 参数 | 必填 | 说明 | |------|------|------| -| `--url` | 二选一 | 笔记 URL(`xhslink.com` 短链或 `xiaohongshu.com/explore/...` 完整链接),脚本自动解析 note_id + xsec_token | +| `--url` | 二选一 | 笔记 URL(`xhslink.com` / `xhslink.cn` 短链或 `xiaohongshu.com/explore/...` 完整链接),脚本自动解析 note_id + xsec_token | | `--note-id` | 二选一 | 小红书笔记 ID(与 `--url` 二选一) | | `--xsec-token` | `--note-id` 时必填 | xsec_token(用 `--note-id` 时必传,否则 HTML 路线拿空页;用 `--url` 时脚本自动提取) | | `--xsec-source` | 否 | xsec_source,默认 `pc_feed` | @@ -88,7 +88,7 @@ xhs-content-ops \ ## 注意事项 -- **控制频率**:批量下载时间隔 5-10 秒,串行执行,不并发。 +- **控制频率**:批量下载时间隔 5-10 秒,串行执行,不并发。note 详情页连读会触发**速度型软风控**(页面被换成 `website-login/error?error_code=300017/300031` 或「安全限制」文案)——脚本会 cooldown 后单次重试,但根源靠调用方控制节奏,被挡(exit 3)时应显著拉大间隔或暂停。 - **仅处理图文笔记**:遇到视频笔记(返回 `VIDEO_NOTE`),提示转 `viral-chaser`。 - 复合流程(搜索 → 筛选 → 批量下载 → 分析)中每一步都应向用户报告进度。 @@ -99,5 +99,6 @@ xhs-content-ops \ | `exit 1` + `NO_XSEC_TOKEN` | 缺 xsec_token;从笔记链接里补提后重试 | | `exit 1` + `NEED_VERIFY` | 触发滑块;停止重试,走重登流程或换时间再试 | | `exit 2`(cookie 回退也失败) | `login-manager` 重登 `xhs-browse` 后重试一次 | +| `exit 3` + `SECURITY_BLOCK` | 速度型风控软屏蔽(**非登录失效**,勿重登)。脚本内已 cooldown 单次重试仍被挡;调用方降速(拉大间隔)后晚些重试,勿短间隔连读 | | 笔记无法访问 | 该笔记可能已删除或设为私密,跳过 | | 视频笔记 | 转 `viral-chaser` | diff --git a/crews/main/skills/expert-xhs/tools/xhs-content-ops/scripts/fetch_note_content.ts b/crews/main/skills/expert-xhs/tools/xhs-content-ops/scripts/fetch_note_content.ts index b738b31f..8e97e85a 100755 --- a/crews/main/skills/expert-xhs/tools/xhs-content-ops/scripts/fetch_note_content.ts +++ b/crews/main/skills/expert-xhs/tools/xhs-content-ops/scripts/fetch_note_content.ts @@ -21,6 +21,7 @@ * 0 Success(含 VIDEO_NOTE 提示——视频笔记交 viral-chaser,非错误) * 1 General error / 无 xsec_token / SIGN_UNAVAILABLE * 2 Cookie expired → trigger login-manager(cookie 回退仍失败时) + * 3 SECURITY_BLOCK → 速度型风控软屏蔽(非登录失效):降速冷却后重试,勿短间隔连读 */ import { mkdirSync, writeFileSync } from "fs" @@ -55,7 +56,7 @@ for (let i = 0; i < args.length; i++) { async function resolveXhsUrl(rawUrl: string): Promise<{ noteId: string; xsecToken: string; xsecSource: string }> { let resolved = rawUrl const hostname = (() => { try { return new URL(rawUrl).hostname } catch { return "" } })() - if (hostname === "xhslink.com") { + if (["xhslink.com", "xhslink.cn"].includes(hostname)) { try { const { stdout } = await execFileAsync( "curl", @@ -109,12 +110,13 @@ if (!xsecToken) { // 用 xhs-browse 同指纹 UA + cookie 重试一次。同时导入 cookie + UA——同一指纹下的 // cookie 才不会被风控错配(spec §4 原则 4)。 -import { loadCookies, loadUa } from "../../_shared/check-session.ts" +import { loadCookies, loadUa } from "../../../../_shared/check-session.ts" import { fetchXhsNoteFromHtml, XhsCaptchaError, XhsNoteInaccessibleError, -} from "../../_shared/xhs-html-note.ts" + XhsSecurityBlockError, +} from "../../../../_shared/xhs-html-note.ts" const XHS_BROWSE_PLATFORM = "xhs-browse" @@ -177,6 +179,15 @@ async function main(): Promise { try { note = await fetchXhsNoteFromHtml(noteId, { xsecToken, xsecSource }) } catch (e) { + if (e instanceof XhsSecurityBlockError) { + // 软风控 ≠ 登录失效:不触发 cookie 回退(换 session 不是软风控的恢复路径), + // 交调用方降速后重试。cooldown 单次重试已在 fetchXhsNoteFromHtml 内完成并失败。 + process.stderr.write(`[xhs-content-ops] ⛔ ${e.message}\n`) + process.stdout.write( + JSON.stringify({ ok: false, error: "SECURITY_BLOCK", msg: "速度型风控软屏蔽:稍后降速重试,勿短间隔连读" }) + "\n", + ) + process.exit(3) + } if (e instanceof XhsCaptchaError || e instanceof XhsNoteInaccessibleError) { if (!cookieStr) { // 无 cookie 回退可用 → cookie 可能过期,交 login-manager @@ -191,6 +202,13 @@ async function main(): Promise { try { note = await fetchXhsNoteFromHtml(noteId, { xsecToken, xsecSource, cookieStr, ua: sessionUa }) } catch (e2) { + if (e2 instanceof XhsSecurityBlockError) { + process.stderr.write(`[xhs-content-ops] ⛔ cookie 回退也命中软风控:${e2.message}\n`) + process.stdout.write( + JSON.stringify({ ok: false, error: "SECURITY_BLOCK", msg: "速度型风控软屏蔽:稍后降速重试,勿短间隔连读" }) + "\n", + ) + process.exit(3) + } if (e2 instanceof XhsCaptchaError) { process.stdout.write(JSON.stringify({ ok: false, error: "NEED_VERIFY", msg: "小红书出现安全验证滑块,请扫码验证后重试" }) + "\n") process.exit(1) diff --git a/crews/main/skills/expert-xhs/tools/xhs-publish/SKILL.md b/crews/main/skills/expert-xhs/tools/xhs-publish/SKILL.md index 977f5fb5..fb3568f8 100644 --- a/crews/main/skills/expert-xhs/tools/xhs-publish/SKILL.md +++ b/crews/main/skills/expert-xhs/tools/xhs-publish/SKILL.md @@ -84,6 +84,20 @@ xhs-publish --mode video --title "笔记标题" --body "正文内容" --video vi > **⚠️ `--body` 必须传实际文字,不能传文件路径或 `$(cat file)`**:exec sandbox 禁用 `$(...)` 命令替换,`--body post.md` 也会被当字面量字符串。把正文直接硬编码进命令。 +> **⚠️ `--body` 换行用真实换行,不要在双引号里写 `\n`**:bash 双引号内的 `\n` 是字面量「反斜杠+n」,发布后正文会全是 `\n` 文本。脚本已兜底把字面量 `\n` 自动归一化为真实换行,但传参仍首选真换行: +> +> ```bash +> # ✅ 多行字符串:引号内直接回车换行 +> xhs-publish --mode image --title "标题" --body "第一行 +> 第二行" --images img.jpg +> +> # ✅ $'...' 转义:\n 被 bash 解释为真实换行 +> xhs-publish --mode image --title "标题" --body $'第一行\n第二行' --images img.jpg +> +> # ❌ 普通双引号里的 \n 是字面量,发布后正文全是 \n 文本 +> xhs-publish --mode image --title "标题" --body "第一行\n第二行" --images img.jpg +> ``` + 成功输出: ```json diff --git a/crews/main/skills/expert-xhs/tools/xhs-publish/scripts/publish_xhs.py b/crews/main/skills/expert-xhs/tools/xhs-publish/scripts/publish_xhs.py index 2ffef2af..7512b257 100755 --- a/crews/main/skills/expert-xhs/tools/xhs-publish/scripts/publish_xhs.py +++ b/crews/main/skills/expert-xhs/tools/xhs-publish/scripts/publish_xhs.py @@ -132,6 +132,16 @@ def cookie_str(cookie_dict: dict) -> str: return "; ".join(f"{k}={v}" for k, v in cookie_dict.items()) +def normalize_body_newlines(body: str) -> str: + r"""把 body 里字面量 \n / \r\n(反斜杠+字母序列)归一化为真实换行。 + + Agent 在 bash 双引号里传 --body 时,引号内 \n 是字面量「反斜杠+n」而非真实换行, + 原样透传会被小红书当普通文本展示,正文全是 \n 文本。 + 先替换 \r\n 再替换 \n,避免残留字面量 \r;已是真实换行的内容不受影响。 + """ + return body.replace("\\r\\n", "\n").replace("\\n", "\n") + + def extract_topics(body: str, extra_topics: list[str] | None = None) -> list[dict]: """Extract #话题 from body text, return AiToEarn-format hash_tag list. @@ -717,6 +727,10 @@ def main() -> None: parser.add_argument("--cookie-file", type=Path, help="Cookie file path") args = parser.parse_args() + # 字面量 \n 归一化须在长度校验 / 话题提取之前:字面量 \n 占 2 字符,先归一化长度才准; + # 且紧贴 #话题 的 \n(非空白字符)会污染 extract_topics 的分词 + args.body = normalize_body_newlines(args.body) + if len(args.title) > 20: err_exit("TITLE_TOO_LONG: title exceeds 20 characters") if len(args.body) > 1000: diff --git a/crews/main/skills/expert-xhs/tools/xhs-style-profiler/references/video-dna-framework.md b/crews/main/skills/expert-xhs/tools/xhs-style-profiler/references/video-dna-framework.md index 696c1029..36196b98 100644 --- a/crews/main/skills/expert-xhs/tools/xhs-style-profiler/references/video-dna-framework.md +++ b/crews/main/skills/expert-xhs/tools/xhs-style-profiler/references/video-dna-framework.md @@ -22,7 +22,7 @@ DNA 文档 -> DNA template |--------|----------| | 选题与观看理由、标题与封面写法、内容创意原型、匹配的用户问题(搜索流量)、业务植入套路、互动引导与 CTA 套路、视频内容形态与制作指向、制作规格与视听倾向、(口播类)口播文案子DNA、(对标账号)账号运营子模块 | 创作细节、脚本结构、逐句台词、镜头表、转场与编码参数——那些归 Content Producer | -视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与(口播类的)**口播文案**。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 +视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与**口播**(真人出镜 / 数字人)的**口播文案**;旁白(剪辑配的解说)由 CP 写,不在 DNA 指导范围。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 ## 维度(11 维) @@ -104,14 +104,14 @@ DNA template = **Brief.md 正文模板 + 口播文案模板(可选)**,固 | DNA 观测到的内容形态 | 制作指向(只能写真实存在的资源名) | Brief `workflow` 字段值 | |---------------------|-----------------------------------|------------------------| | 影视解说 / 剧情解说 + 反转植入(「万万没想到」式) | Content Producer `expert-video` → **Reversal Ad** workflow | `reversal-ad` | -| 口播类(真人口播出镜,或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | +| 口播 / 旁白类(真人口播或数字人出镜;或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | | 一句文稿转视觉隐喻的纸拼贴动画 | Content Producer `expert-video` → **Collage B-roll** workflow | `collage-broll` | -| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,Stage 1 定档位(narrative / motion / montage) | 省略 | +| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,据创意自定叙事 / 动效 / 蒙太奇手法 | 省略 | | 已有素材简单拼接、加旁白、烧字幕 | main `video-edit`(不委托 CP) | — | | 已有真人口播素材去口气词、剪高光 | main `talking-head-cut`(不委托 CP) | — | | 产品操作录屏 | main `ui-demo`(不委托 CP) | — | -Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,档位由 Stage 1 `intent-router` 定。 +Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。 ## Focus ID 表 diff --git a/crews/main/skills/expert-xhs/tools/xhs-style-profiler/scripts/build_style_profile.py b/crews/main/skills/expert-xhs/tools/xhs-style-profiler/scripts/build_style_profile.py index dc475752..d6226e85 100755 --- a/crews/main/skills/expert-xhs/tools/xhs-style-profiler/scripts/build_style_profile.py +++ b/crews/main/skills/expert-xhs/tools/xhs-style-profiler/scripts/build_style_profile.py @@ -105,7 +105,7 @@ "title-cover": "- 单篇观测:标题类型(痛点 / 数字 / 反差 / 悬念 / 身份点名 / 搜索长尾)、标题与描述原文、话题标签策略。\n- 视觉证据:封面或首帧必须由视觉模型读取图片,至少提取画面主体与场景、构图与画幅、色彩体系、光线与质感、风格与媒介、文字视觉与图文关系、品牌识别元素、避免项,并反推为可执行的 AIGC 提示词要素;无图片写「未提供」,不得凭正文或标题想象补齐。", "content-idea": "- 单篇观测:一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点。\n- 可复用信号:这个创意套路换成别的主题还能怎么用。\n- 边界:只记创意层,不记创作细节(逐句台词、镜头表、脚本结构、转场与编码参数)。", "search-intent": "- 单篇观测:本篇命中的关键词(核心词 / 痛点词 / 场景词 / 人群词)、用户可能的提问原句、搜索意图层级、标签承载的搜索意图。\n- 边界:单篇只记候选;聚合后才形成「关键词 → 用户问题 → 内容形式」的搜索意图地图。", - "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程 + Stage 1 定档位),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", + "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程据创意自定手法),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", "production-spec": "- 单篇观测:横屏或竖屏、时长带、画面风格(色调、质感、字幕样式倾向、信息密度)、配音音色与声音形态(原声口播 / TTS / 旁白 / 纯画面字幕)、BGM 与音效倾向、封面规格。\n- 边界:只记规格与倾向,不规定镜头参数、逐镜设计、转场与编码细节——那些归 Content Producer。", "narration-script": "- 子模块(仅口播类作品启用):起(从什么起步)、承(靠什么推进)、转(转折触发)、合(收束方式;CTA 的目标、位置与句式记在 `interaction-cta`),以及人称与语气、句长与语速、签名式表达。\n- 边界:非口播类或证据不足时写「未启用 / 未观测」;不得把单篇句式直接上升为规则。", "body-voice": "- 单篇观测:开头钩子(原文摘录)、正文组织方式(清单体 / 教程步骤 / 故事线 / 对比 / 观点输出)、分行与段落节奏、口语化程度与人称、emoji 与标点用法、签名式表达。\n- 证据边界:脚本统计只给句长 / 行数 / emoji / 标签等线索;口头禅与签名表达必须回读原文确认。", @@ -146,7 +146,7 @@ } TEMPLATE_CHECKLISTS = { - "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题 / 描述、封面是否来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类是否附口播文案(或真人口播录音路径);口播子模块未启用时是否避免规定逐句口播。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 关键词是否落到用户可能的提问原句(不只平台标签),并与内容形式匹配。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", + "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题 / 描述、封面是否来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类(真人出镜 / 数字人 / 真人录音)是否附口播终稿(`voiceover.md` 绝对路径)或真人录音路径——无论口播子模块是否启用;剪辑配解说的旁白归 CP 写,Brief 口播字段写「不适用」。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 关键词是否落到用户可能的提问原句(不只平台标签),并与内容形式匹配。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", "note": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题与封面图组是否来自 DNA 文档。\n- 正文表达的每条规则是否可从 DNA 文档推导,未使用空泛形容词。\n- 图组数量、构图与视觉风格是否与 DNA 一致;视觉结论是否有图片证据。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「软性推荐」),CTA 是否每篇只放一个主行动、句式可执行且未越合规红线。\n- 关键词是否落到用户可能的提问原句(不只平台标签),并与内容形式匹配。\n- 用户输入是否已转译为具体执行规则。", } diff --git a/crews/main/skills/expert-xhs/workflows/content-production.md b/crews/main/skills/expert-xhs/workflows/content-production.md index 0924e76e..b1074526 100644 --- a/crews/main/skills/expert-xhs/workflows/content-production.md +++ b/crews/main/skills/expert-xhs/workflows/content-production.md @@ -81,7 +81,7 @@ DNA template 是 main agent 的生产输入模板:**图文 DNA 的 template = | 标题 | 硬限制 ≤ 20 字;覆盖主关键词或用户问题 | | 图组 | 硬限制 ≤ 18 张;用户提供优先 | | 行动引导 | 只放一个平台内动作 | -| workflow | 视频全案已确定形态时写 CP `expert-video` 支持的 workflow(reversal-ad / narration-video / collage-broll);未确定则省略 | +| workflow | 视频全案已确定形态时写 CP `expert-video` 支持的 workflow(reversal-ad / narration-video / collage-broll);未确定则省略(省略 = CP 按其通用制作流程做) | 优先级: @@ -162,7 +162,7 @@ DNA template 是 main agent 的生产输入模板:**图文 DNA 的 template = - 视频名 / slug: - platform:xhs -- workflow:reversal-ad / narration-video / collage-broll / 未指定(未指定 = CP 按其通用制作流程做,Stage 1 自定档位) +- workflow:reversal-ad / narration-video / collage-broll(视频形态未确定时省略本字段;省略 = CP 按其通用制作流程做,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定) - 选题与观看理由: - 核心传达: - 内容创意:创意原型 + 展开逻辑 + 记忆点 @@ -171,7 +171,7 @@ DNA template 是 main agent 的生产输入模板:**图文 DNA 的 template = - 标题与简介:笔记标题、发布正文、话题标签(main 定稿) - 封面要求:封面主文案 + 视觉方向 - 制作规格:横屏 / 竖屏、时长带、画面风格、配音音色与声音形态、BGM 与音效、字幕 -- 口播文案:`voiceover.md` 绝对路径(口播类必填)/ 真人口播录音绝对路径 / 不适用 +- 口播文案:`voiceover.md` 绝对路径 / 真人口播录音绝对路径 / 不适用(口播 = 真人出镜、数字人、真人录音;剪辑配解说的**旁白归 CP 写**,写「不适用」) - 素材清单:逐条**绝对路径** + 来源 + 授权(无素材时写「无,由 CP 按 Brief 取材」) - 交付物与验收:`video.mp4` + `cover.jpg` + `final-deliver.md`,回报三者绝对路径;验收标准 - 闸门:GATE A / GATE B 批准人(用户或 main 代理批准 + 批准范围) @@ -187,9 +187,9 @@ Brief 硬性规则: ### 口播文案规则 -- **口播类视频的口播文案由 main agent 出**:按 DNA 文档的 `narration-script`(口播文案子模块)写终稿,保存为作品目录下的 `voiceover.md`,在 Brief 中给**绝对路径**。CP 不重写策略文案,只做声画实现。 +- **口播(真人出镜 / 数字人 / 真人录音)的口播稿一律由 main agent 出**:`narration-script`(口播文案子模块)启用时按其结构写,未启用时按用户要求与 Brief 核心传达写,保存为作品目录下的 `voiceover.md`,在 Brief 中给**绝对路径**。CP 不重写,只做声画实现;剪辑配解说的**旁白归 CP 写**,main 不出旁白稿。 - **真人口播**:明确要用用户真人声音时,指导用户按口播稿录音,完成后向用户取得录音文件,落到作品目录并在 Brief 中给绝对路径。 -- **口播子模块未启用**:Brief 写明「口播文案:不适用」或只给要点,由 CP 按其 workflow 组织旁白;main 不再规定逐句台词。 +- **口播子模块未启用**:口播终稿仍由 main 写——按用户要求与 Brief 的核心传达 / CTA 要求直接撰写(不依赖 DNA 子模块),落 `voiceover.md` 并在 Brief 给绝对路径;「不适用」即旁白类(剪辑配解说归 CP 写)或非口播视频。 - 用户必用的事实、案例、承诺和 CTA 必须进入 Brief 或口播终稿,不得为了形式删除关键事实。 ## 【确认】正文 / Brief @@ -205,7 +205,7 @@ Brief 硬性规则: ### 图文图组 1. 读取 DNA 的图组视觉与标题封面结论,只取风格边界。 -2. 图片来源优先级:用户素材 → `campaign_assets/` → `siliconflow-img-gen` → `pexels-footage` / `pixabay-footage`。 +2. 图片来源优先级:用户素材 → `campaign_assets/` → `awk-img-gen` → `pexels-footage` / `pixabay-footage`。 3. 封面必须存在,兑现标题承诺;生成图发用户确认。 4. 图文建议 3:4 竖版,图片 ≤ 18 张,顺序按信息推进。 5. 图片与正文分工:图承载证据、过程、对比或清单;文承载判断、细节与行动。 @@ -216,9 +216,9 @@ Brief 硬性规则: | --- | --- | | 用户直接提供成片 | 校验格式与时长,复制到作品目录 | | 已有素材需简单加工 | main 用 `video-edit` / `talking-head-cut` 处理 | -| 全案制作 | 委托 `content-producer`:只交 Brief + 素材绝对路径 + 口播文案 / 录音,不指定 CP 工作区;指定 `workflow` 必须采用;未指定时 CP 按其通用制作流程做,档位由 Stage 1 定。成片与封面按 CP 回报的绝对路径取回作品目录 | +| 全案制作 | 委托 `content-producer`:只交 Brief 一份(素材、口播文案 / 录音以绝对路径写在 Brief 内),不指定 CP 工作区;指定 `workflow` 必须采用;未指定时 CP 按其通用制作流程做,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定;Brief 缺失或创意不足以直接写剧本时,CP 会走其 story-develop intake workflow 与 Brief owner 收敛 Brief。成片与封面按 CP 回报的绝对路径取回作品目录 | -视频封面优先从成片选帧;需要更强视觉冲击时用 `siliconflow-img-gen`。 +视频封面优先从成片选帧;需要更强视觉冲击时用 `awk-img-gen`。 ## 【确认】正文与图组 / 成片与封面 diff --git a/crews/main/skills/expert-xhs/workflows/editing.md b/crews/main/skills/expert-xhs/workflows/editing.md index 74dffd76..6008cc51 100644 --- a/crews/main/skills/expert-xhs/workflows/editing.md +++ b/crews/main/skills/expert-xhs/workflows/editing.md @@ -13,7 +13,7 @@ | "改下标题" | 标题层 | 按 `title-cover` 与 `search-intent` 重写,给 2-3 个候选 | | "正文改改 / 精简 / 扩写 / 换开头" | 正文层 | 按 `topic-angle`、`content-idea`、`search-intent` 与互动目标局部调整 | | "标签换一下 / 加标签" | 标签层 | 按 template 话题标签策略重组(≤10 个硬限制) | -| "换封面 / 换图 / 加图" | 图组层 | 用户指定 / `siliconflow-img-gen` 按 `imageset-visual` 重做 | +| "换封面 / 换图 / 加图" | 图组层 | 用户指定 / `awk-img-gen` 按 `imageset-visual` 重做 | | "结构调一下 / 改成清单体" | 结构层 | 先出调整方案(新的段落组织与信息顺序)-> 确认 -> 重写正文 | | "换个风格 / 用另一个 DNA 写" | 方向层 | 明确目标 `dna-id`,按目标 template 重写;等于一次小型再生产 | | "方向不对 / 选题要换" | 选题层 | 回到 `content-production.md` 从选题重新走 | @@ -45,7 +45,7 @@ ## 图组层 1. 用户直接指定图片 -> 直接使用,复制进笔记目录。 -2. 用户说"重做封面" -> 按 DNA `imageset-visual`、标题承诺与核心收益,用 `siliconflow-img-gen` 生成,用户确认后替换。 +2. 用户说"重做封面" -> 按 DNA `imageset-visual`、标题承诺与核心收益,用 `awk-img-gen` 生成,用户确认后替换。 3. 增删配图:总数 ≤ 18 张;顺序按正文信息推进重排;替换后更新 `note.md` 同目录图片与引用。 4. 图文建议 3:4 竖版;封面(首图)必须存在。 diff --git a/crews/main/skills/expert-xhs/workflows/style-dna.md b/crews/main/skills/expert-xhs/workflows/style-dna.md index 28ea35d2..e1f073da 100644 --- a/crews/main/skills/expert-xhs/workflows/style-dna.md +++ b/crews/main/skills/expert-xhs/workflows/style-dna.md @@ -149,7 +149,7 @@ xhs-style-profiler update \ - **图文笔记**:读取图文 DNA 文档与 template(含关键词与用户问题段),main agent 直接生产。 - **视频全案**:读取视频 DNA 文档与 template,main agent 产出 **Brief**(+ 口播类的口播文案)。Brief 写明选题与观看理由、标题与简介、内容创意、关键词与用户问题、`workflow`(视频形态的制作指向)、制作规格、素材清单与授权(绝对路径)、验收标准、闸门批准人。 - **Brief 不含 DNA 信息**:Content Producer 看不到 main 的 DNA,只按 Brief 制作;也不要把 DNA 文档路径写进 Brief。 -- **口播类视频**:口播文案子模块启用时,口播终稿由 main agent 写好并随 Brief 交付;真人口播时由 main agent 向用户取得录音文件。CP 不重写策略文案。 +- **口播类视频**:口播终稿一律由 main agent 写好并随 Brief 交付(`narration-script` 子模块启用时按其结构写,未启用时按用户要求与 Brief 核心传达写);真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写。 - **工作区**:main 不替 CP 建工作区,也不指定项目目录;CP 自建工作区,双方 T3 权限可互访取文件。 ## 对标接口 diff --git a/crews/main/skills/login-manager/SKILL.md b/crews/main/skills/login-manager/SKILL.md index 4d31258d..02ba4f98 100644 --- a/crews/main/skills/login-manager/SKILL.md +++ b/crews/main/skills/login-manager/SKILL.md @@ -98,3 +98,5 @@ cookie 和 UA **必须同时导出**——同一指纹下的 cookie 才不会被 **严禁 cookie import 造会话**:浏览器操作一律走真实登录后的**持久化 session**(登录态 + 指纹冻结在 session profile 里),不开临时 session 再 `cookies import`。xhs `a1`/`websectiga` 等设备指纹 cookie 导入到不同指纹的浏览器会话会错配 → 被风控检测。中央存储的 cookie+UA 只给下游**脚本**做 raw HTTP 抓取用(拼进 header 直接发请求,不经浏览器)。 **HTML 登录墙检测**(脚本 / 纯 HTTP 用):下游 raw HTTP fetch 期望 JSON 时,session 失效平台可能返回 HTML 登录页(200 `text/html` 或 302→login)而非 JSON error,`resp.json()` 抛乱码错。`_shared/relay-sign.ts` 的 `xhsFetch` 已内置登录墙检测(content-type 含 `text/html` 或 body 以 HTML 标签开头 → 抛 `LoginWallError`,消息以 `SESSION_EXPIRED:` 起头),下游捕获后 emit `SESSION_EXPIRED` + exit 2。新增 raw-HTTP 脚本若不走 `xhsFetch` 应复用同款检测(正则大小写不敏感)。 + +**软风控 ≠ 登录失效**:xhs 速度型软风控页(redirect `website-login/error?error_code=300017/300031` 或「安全限制/请求太频繁」文案)也是 HTML,但语义是**节奏风控**不是登录过期。`xhsFetch` 会先判软风控抛 `XhsSecurityBlockError`(`SECURITY_BLOCK:` 起头)再判登录墙;HTML 路线 `_shared/xhs-html-note.ts` 同款。pong 撞软风控判 UNKNOWN 放行(`check-session.ts` pongXhs,同 douyin status_code=4 先例),**不触发重登**。下游捕获到 SECURITY_BLOCK 应降速/冷却重试。 diff --git a/crews/main/skills/published-track/SKILL.md b/crews/main/skills/published-track/SKILL.md index 63fb50ab..a5080467 100644 --- a/crews/main/skills/published-track/SKILL.md +++ b/crews/main/skills/published-track/SKILL.md @@ -51,7 +51,7 @@ published-track init-db 每张表共享通用字段:`id`(自增主键)、`title`、`content_type`(article/video/post)、`source_folder`(原始文件夹,如 `wx_mp/outputs/xxx`,**不做唯一约束,同内容可同平台多次发布**)、`publish_url`、`publish_date`(YYYY-MM-DD)、`distribute_status`(0=待分发,1=无需分发,2=已分发)、`notes`、`created_at`、`updated_at`。各平台特有互动指标默认 0,另有 `top_comment`(主要留言摘要)。 -> **视频号(`pub_wx_channel`)特例**:`title` 列存的是**完整视频描述**(含 hashtag,最长约 300 字),即 `wechat-channels-publish` Step 5 填的视频描述;`wx-channel-engagement` 抓取按它匹配后台作品管理页。发布页改版后虽可另填**短标题**,但作品管理页不展示短标题,因此**短标题不入库**——调用方调 `record.sh --platform wx_channel --title` 必须传完整视频描述,不要传短标题、也不要把两者拼接。 +> **视频号(`pub_wx_channel`)特例**:`title` 列存的是**完整视频描述**(含 hashtag,最长约 300 字),即 `wechat-channels-publish` Step 6 填的视频描述;`wx-channel-engagement` 抓取按它匹配后台作品管理页。发布页改版后虽可另填**短标题**,但作品管理页不展示短标题,因此**短标题不入库**——调用方调 `record.sh --platform wx_channel --title` 必须传完整视频描述,不要传短标题、也不要把两者拼接。 ### DNA 关联字段(v3 schema) @@ -112,7 +112,7 @@ published-track record \ ### 流程 2A·自动更新(定时任务用) -`fetch-and-update-metrics.sh` 封装探活 → API 抓取 → DB 写入,凌晨复盘心跳调用(仅 bilibili / douyin / kuaishou 三个纯 HTTP 平台): +`fetch-and-update-metrics.sh` 封装探活 → API 抓取 → DB 写入,凌晨复盘心跳调用(仅 douyin 一个纯 HTTP 平台): ```bash # 通过 source-folder 从 DB 查 publish_url → 抓取 → 写入 @@ -135,7 +135,12 @@ published-track fetch-metrics \ Exit codes:0=成功/浏览器/手动(非错误),1=一般错误,2=SESSION_EXPIRED。 -- **脚本支持**:bilibili、douyin、kuaishou(走 `fetch-retro-data.ts` 纯 HTTP + cookie + UA)。**xhs / wx_mp / wx_channel 均不走本技能的 fetch-metrics**(收到这三个平台直接 exit 1 指路)——xhs 走 `expert-xhs` 专家包内的 `xhs-engagement` 工具,wx_mp 走 `expert-wx-mp` 专家包内的 `wx-mp-engagement` 工具,wx_channel 走 `expert-wx-channel` 专家包内的 `wx-channel-engagement` 工具,三者都是 camoufox 抓平台后台方案,与纯 HTTP 链路机制不同。其他平台暂不支持自动抓取互动数据。 +**douyin 取数双车道**(`fetch-retro-data.ts`): +- 公开侧 `aweme/detail`:点赞/评论/分享/收藏。**play_count 公开侧恒为 0**(播放量仅创作者可见)。 +- 创作侧 `creator/item/list`(`_shared/douyin-web.ts` `douyinCreatorItem`):**播放量唯一来源**(`view_count`)+ 26 字段深指标(5s 完播率 / 2s 跳出率 / 封面曝光与点击率 / 粉丝观看占比 / 关注转化等),**视频与图文(note)作品通用**;creator 域 cookie-only 无需 a_bogus,用中央 douyin cookie 即可。失败时 graceful 降级(只缺播放量/深指标,公开侧数据不受影响)。**深指标存储**:`fetch-and-update-metrics.sh` 经 `--deep-file` 写入 `pub_douyin.deep_metrics`(单行 JSON,`deep_captured_at` / `deep_source` 同行),**只存最新值不留历史**(2026-09-18 定调);`query.sh` 走 `SELECT *`,deep 列自动可见。 +- **链接格式**:视频 `douyin.com/video/`、图文 `douyin.com/note/` 均可提取 content_id(2026-09-17 起支持 note)。 + +- **脚本支持**:douyin(走 `fetch-retro-data.ts` 纯 HTTP + cookie + UA)。**自动取数仅覆盖完全支持 Expert 架构的 4 个平台**:douyin 走本技能 `fetch-metrics`;xhs / wx_mp / wx_channel 均不走本技能的 fetch-metrics(收到这三个平台直接 exit 1 指路)——xhs 走 `expert-xhs` 专家包内的 `xhs-engagement` 工具,wx_mp 走 `expert-wx-mp` 专家包内的 `wx-mp-engagement` 工具,wx_channel 走 `expert-wx-channel` 专家包内的 `wx-channel-engagement` 工具,三者都是 camoufox 抓平台后台方案,与纯 HTTP 链路机制不同。**bilibili / kuaishou 及其余平台不做自动取数**(收到直接 exit 1 报 `PLATFORM_OUT_OF_FETCH_SCOPE`)——发布记录与查询照常支持,只是不抓互动数据。 ### 流程 2B·用户提供数据(Agent 补录) @@ -202,4 +207,10 @@ published-track check-published \ 所有发布技能(wx-mp-publisher、xhs-publish、gaoqian-article、wechat-channels-publish、bilibili-publish 等)的流程统一为 **发布 → 记录**(`published-track record` 带 `--account`;DNA 关联经 `dna-meta.json` 自动建立)。各技能 SKILL.md 的"发布记录"段标注此要求,主 agent 无需额外提醒。 -**平台代号对照**:`wx-mp-publisher`/`sync-from-mp` → `wx_mp`;`wechat-channels-publish` → `wx_channel`;`xhs-publish` → `xhs`; `douyin-publish` → `douyin`;`bilibili-publish` → `bilibili`;`kuaishou-publish` → `kuaishou`;`zhihu-publish` → `zhihu`; `twitter-post` → `twitter`;`weibo-publish` → `weibo`. +**平台代号对照**:`wx-mp-publisher`/`sync-from-mp` → `wx_mp`;`wechat-channels-publish` → `wx_channel`;`xhs-publish` → `xhs`; `douyin-video-publish` / `douyin-note-publish` → `douyin`;`bilibili-publish` → `bilibili`;`kuaishou-publish` → `kuaishou`;`zhihu-publish` → `zhihu`; `twitter-post` → `twitter`;`weibo-publish` → `weibo`. + +## 平台启用状态与定时取数 + +`published-track platform-status --platform ` 只读工作区 `/calibration/platform-state.json` 的 `enabled` 字段,兼容旧 `.platform-state.json`。文件不存在返回 `enabled=false, reason=NOT_INITIALIZED`;字段缺失、类型错误或文件损坏返回 `ok=false, enabled=false` 与错误。不因查询而初始化或启用平台。 + +heartbeat 每个平台取数前查状态,仅 `ok=true, enabled=true` 时取数。抖音用 `published-track query --platform douyin --limit 30` 查询图文与视频合计最近 30 条,按返回的每条 `id` 依次 `published-track fetch-metrics --platform douyin --id `。无需按天数过滤或手传 `--content-id`。登录失效停止该平台,其他单条错误记录后继续。完整定时流程见 HEARTBEAT.md。 diff --git a/crews/main/skills/published-track/published-track.sh b/crews/main/skills/published-track/published-track.sh index 3ce4552f..37cc33e4 100755 --- a/crews/main/skills/published-track/published-track.sh +++ b/crews/main/skills/published-track/published-track.sh @@ -24,6 +24,7 @@ if [ $# -gt 0 ]; then shift; fi case "$cmd" in record) exec bash "$SCRIPT_DIR/scripts/record.sh" "$@" ;; update-metrics) exec bash "$SCRIPT_DIR/scripts/update-metrics.sh" "$@" ;; + platform-status) exec python3 "$SCRIPT_DIR/scripts/platform-status.py" "$@" ;; fetch-metrics) exec bash "$SCRIPT_DIR/scripts/fetch-and-update-metrics.sh" "$@" ;; query) exec bash "$SCRIPT_DIR/scripts/query.sh" "$@" ;; query-pending) exec bash "$SCRIPT_DIR/scripts/query-pending.sh" "$@" ;; @@ -39,7 +40,8 @@ case "$cmd" in 子命令: record 发布记录入库(upsert;自动读 dna-meta.json 落 dna_id) update-metrics 更新单条/同 folder 记录的互动指标 - fetch-metrics 探活→API 抓取→写库(xhs/bilibili/douyin/kuaishou;wx_mp/wx_channel 不走这里) + platform-status 查询平台是否启用(只读 calibration/platform-state.json) + fetch-metrics 探活→API 抓取→写库(仅 douyin;xhs/wx_mp/wx_channel 走各专家包 engagement 工具) query 通用查询(--platform [--limit]) query-pending 查询待分发内容 check-published 查某作品是否已发布 diff --git a/crews/main/skills/published-track/scripts/fetch-and-update-metrics.sh b/crews/main/skills/published-track/scripts/fetch-and-update-metrics.sh index e3819b64..ccd08ed1 100755 --- a/crews/main/skills/published-track/scripts/fetch-and-update-metrics.sh +++ b/crews/main/skills/published-track/scripts/fetch-and-update-metrics.sh @@ -33,17 +33,12 @@ extract_content_id() { local url="$2" case "$platform" in - bilibili) - # https://www.bilibili.com/video/BVxxxxx → BVxxxxx - echo "$url" | sed -n 's|.*/video/\(BV[^/?]*\).*|\1|p' - ;; douyin) - # https://www.douyin.com/video/1234567890 → 1234567890 - echo "$url" | sed -n 's|.*/video/\([0-9]*\).*|\1|p' - ;; - kuaishou) - # https://www.kuaishou.com/short-video/xxx 或 /video/xxx - echo "$url" | sed -n 's|.*/short-video/\([^/?]*\).*|\1|p; s|.*/video/\([^/?]*\).*|\1|p' + # 视频 https://www.douyin.com/video/1234567890 → 1234567890 + # 图文 https://www.douyin.com/note/7686383022777634058 → 7686383022777634058 + # (2026-09-17 xiaobei 反馈:note 链接提取为空报 CANNOT_EXTRACT_CONTENT_ID。 + # 定界符用 #:用 | 的话 \| 被解析成字面管道而非 BRE alternation。) + echo "$url" | sed -n 's#.*/\(video\|note\)/\([0-9]*\).*#\2#p' ;; *) echo "" @@ -56,10 +51,13 @@ extract_content_id() { # 脚本支持的平台(fetch-retro-data.ts 能处理的) # 2026-08-22:xhs 移出——走 xhs-engagement 技能(camoufox creator 后台方案), # 与 wx_mp/wx_channel 同模式,见下方平台路由 -SCRIPT_PLATFORMS="bilibili douyin kuaishou" +# 2026-09-16:bilibili / kuaishou 移出——自动取数范围收窄为完全支持 Expert 架构的 +# 4 个平台(douyin 走本脚本;xhs/wx_mp/wx_channel 走各自专家包的 engagement 工具), +# bilibili/kuaishou 只保留发布记录/查询,不抓互动数据,见下方平台路由 +SCRIPT_PLATFORMS="douyin" # 需要 cookie 的平台 -COOKIE_PLATFORMS="douyin kuaishou" +COOKIE_PLATFORMS="douyin" # 只能手动提供数据的平台 # Phase 4.6:wx_mp 已接入 wx-mp-engagement skill 自动抓取,移出手动列表 @@ -93,15 +91,23 @@ fi LM_PLATFORM="$PLATFORM" case "$LM_PLATFORM" in douyin) PLATFORM_HOME="https://www.douyin.com/" ;; - bilibili) PLATFORM_HOME="https://www.bilibili.com/" ;; - kuaishou) PLATFORM_HOME="https://www.kuaishou.com/" ;; *) PLATFORM_HOME="" ;; esac # ─── 平台路由 ────────────────────────────────────────────────────────────── +# bilibili / kuaishou **不走本脚本**——2026-09-16 起自动取数范围收窄为完全支持 +# Expert 架构的 4 个平台(douyin 走本脚本;xhs/wx_mp/wx_channel 走各自专家包的 +# engagement 工具)。bilibili/kuaishou 只保留发布记录/查询,不自动抓互动数据。 +case "$PLATFORM" in + bilibili|kuaishou) + echo "{\"ok\":false,\"error\":\"PLATFORM_OUT_OF_FETCH_SCOPE\",\"platform\":\"$PLATFORM\",\"hint\":\"自动取数仅覆盖完全支持 Expert 架构的 4 个平台(douyin/xhs/wx_mp/wx_channel)。bilibili/kuaishou 不做自动取数,发布记录与查询照常支持\"}" + exit 1 + ;; +esac + # wx_mp(微信公众号)**不走本脚本**——它走 camoufox 抓创作者中心的方案, -# 与 bilibili/douyin/kuaishou 的纯 HTTP+cookie 链路完全不同, +# 与 douyin 的纯 HTTP+cookie 链路完全不同, # 由 expert-wx-mp 专家包内的 wx-mp-engagement 工具独立承担(agent 直调 wx-mp-engagement wrapper)。 # 见 crews/main/HEARTBEAT.md Step 2 与 crews/main/skills/expert-wx-mp/tools/wx-mp-engagement/SKILL.md。 if [ "$PLATFORM" = "wx_mp" ]; then @@ -118,7 +124,7 @@ if [ "$PLATFORM" = "xhs" ]; then fi # wx_channel(微信视频号)**不走本脚本**——它走 camoufox 抓视频号助手后台的方案, -# 与 bilibili/douyin/kuaishou 的纯 HTTP+cookie 链路完全不同, +# 与 douyin 的纯 HTTP+cookie 链路完全不同, # 由 expert-wx-channel 专家包内的 wx-channel-engagement 工具独立承担(agent 直调同名 wrapper)。 # 见 crews/main/HEARTBEAT.md Step 2 与 crews/main/skills/expert-wx-channel/tools/wx-channel-engagement/SKILL.md。 if [ "$PLATFORM" = "wx_channel" ]; then @@ -181,7 +187,7 @@ if [ "$NEEDS_COOKIE" = true ]; then echo "{\"ok\":false,\"error\":\"CHECK_LOGIN_SCRIPT_NOT_FOUND\",\"platform\":\"$PLATFORM\",\"hint\":\"check-login.ts 不存在于 $SCRIPT_DIR/\"}" exit 1 fi - CHECK_OUT=$(node --experimental-strip-types "$CHECK_LOGIN" --platform "$PLATFORM" 2>/dev/null) || CHECK_EXIT=$? + CHECK_OUT=$(node --experimental-strip-types "$CHECK_LOGIN" --platform "$PLATFORM") || CHECK_EXIT=$? CHECK_EXIT=${CHECK_EXIT:-0} if [ "$CHECK_EXIT" -eq 2 ]; then CHECK_REASON=$(printf '%s' "$CHECK_OUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{try{console.log(JSON.parse(d).reason||"")}catch{}})' 2>/dev/null) @@ -263,7 +269,7 @@ fi echo "[fetch-and-update] 调 fetch-retro-data.ts --platform $PLATFORM --content-id $CONTENT_ID ..." >&2 # stdout = JSON 结果,stderr = 进度日志(透传) -FETCH_OUTPUT=$(node --experimental-strip-types "$FETCH_SCRIPT" --platform "$PLATFORM" --content-id "$CONTENT_ID" 2>/dev/null) || FETCH_EXIT=$? +FETCH_OUTPUT=$(node --experimental-strip-types "$FETCH_SCRIPT" --platform "$PLATFORM" --content-id "$CONTENT_ID") || FETCH_EXIT=$? FETCH_EXIT=${FETCH_EXIT:-0} if [ "$FETCH_EXIT" -eq 2 ]; then @@ -280,6 +286,8 @@ fi # 将 fetch-retro-data.ts 的 JSON 输出转换为 update-metrics.sh 参数 # 用临时文件传递 JSON(避免多行 JSON 在 bash heredoc 中出问题) FETCH_TMP=$(mktemp) +DEEP_TMP=$(mktemp) +trap 'rm -f "$DEEP_TMP"' EXIT echo "$FETCH_OUTPUT" > "$FETCH_TMP" METRICS_PARAMS=$(node -e " @@ -288,9 +296,8 @@ if (!data.ok) { console.log('__fetch_failed__:' + (data.error || 'UNKNOWN') + ': const stats = data.stats || {}; const args = []; const mapping = { - // viewCount → 'plays':pub_bilibili / pub_kuaishou 的播放列叫 plays(非 views)。 - // 此 mapping 仅对 SCRIPT_PLATFORMS=bilibili/douyin/kuaishou 生效,其中 - // bili/kuaishou 返回 viewCount 且 DB 列为 plays;douyin 返回 playCount,均不受影响。 + // viewCount → 'plays' 等通用映射保留(fetch-retro-data.ts 可能返回多种键名); + // 当前 SCRIPT_PLATFORMS 仅 douyin,返回 playCount → plays。 viewCount: 'plays', plays: 'plays', playCount: 'plays', views: 'views', likeCount: 'likes', likes: 'likes', commentCount: 'comments', comments: 'comments', @@ -307,18 +314,21 @@ const mapping = { }; for (const [k, v] of Object.entries(stats)) { const mapped = mapping[k]; - if (mapped && v > 0) { + if (mapped && typeof v === 'number' && Number.isFinite(v) && v >= 0) { args.push('--' + mapped + '=' + v); } } -// 只取互动计数,不抓评论(参考 wiseflow4-pro 各平台 processor:detail-only,风控最低)。 -// 即使无 stats 也输出 __empty__ 标记,避免被 bash 判为空 +// deep 指标 → 单行 JSON 文件(update-metrics --deep-file readfile 读入, +// 免引号地狱),stdout 标记只表达标量指标有无 +if (data.deep && typeof data.deep === 'object' && !Array.isArray(data.deep)) { + require('fs').writeFileSync(process.argv[2], JSON.stringify(data.deep)); +} if (args.length === 0) { console.log('__no_metrics__'); } else { console.log(args.join(' ')); } -" "$FETCH_TMP" 2>/dev/null) || METRICS_EXIT=$? +" "$FETCH_TMP" "$DEEP_TMP" 2>/dev/null) || METRICS_EXIT=$? METRICS_EXIT=${METRICS_EXIT:-0} rm -f "$FETCH_TMP" @@ -336,10 +346,14 @@ if [[ "$METRICS_PARAMS" == __fetch_failed__:* ]]; then exit 1 fi -# 无指标数据但 API 调用成功——直接返回成功 +# 无标量指标但 API 调用成功:有 deep → 继续 deep-only 写入;无 deep → 直接返回成功 if [ "$METRICS_PARAMS" = "__no_metrics__" ]; then - echo "{\"ok\":true,\"method\":\"script\",\"platform\":\"$PLATFORM\",\"content_id\":\"$CONTENT_ID\",\"note\":\"API 返回成功但无互动指标数据(内容可能不存在或数据尚未产生)\"}" - exit 0 + if [ -s "$DEEP_TMP" ]; then + METRICS_PARAMS="" + else + echo "{\"ok\":true,\"method\":\"script\",\"platform\":\"$PLATFORM\",\"content_id\":\"$CONTENT_ID\",\"note\":\"API 返回成功但无互动指标数据(内容可能不存在或数据尚未产生)\"}" + exit 0 + fi fi # Step 5: 调 update-metrics.sh @@ -353,8 +367,14 @@ else echo "{\"ok\":false,\"error\":\"NO_LOCATE_KEY\",\"platform\":\"$PLATFORM\",\"hint\":\"写库需要 --id 或 --source-folder 定位记录,仅传 --content-id 无法更新\"}" exit 1 fi -eval "\"$UPDATE_SCRIPT\" --platform \"$PLATFORM\" ${UPDATE_LOCATE[*]} $METRICS_PARAMS" 2>/dev/null || UPDATE_EXIT=$? +# deep 指标参数(mktemp 路径无空格,eval 安全) +DEEP_ARGS=() +if [ -s "$DEEP_TMP" ]; then + DEEP_ARGS=(--deep-file "$DEEP_TMP" --deep-source "${PLATFORM}:creator_item_list") +fi +eval "\"$UPDATE_SCRIPT\" --platform \"$PLATFORM\" ${UPDATE_LOCATE[*]} $METRICS_PARAMS ${DEEP_ARGS[*]}" >&2 || UPDATE_EXIT=$? UPDATE_EXIT=${UPDATE_EXIT:-0} +rm -f "$DEEP_TMP" if [ "$UPDATE_EXIT" -ne 0 ]; then echo "{\"ok\":false,\"error\":\"UPDATE_FAILED\",\"platform\":\"$PLATFORM\",\"hint\":\"update-metrics.sh 执行失败 (exit $UPDATE_EXIT)\"}" @@ -362,4 +382,4 @@ if [ "$UPDATE_EXIT" -ne 0 ]; then fi # 成功 -echo "{\"ok\":true,\"method\":\"script\",\"platform\":\"$PLATFORM\",\"content_id\":\"$CONTENT_ID\",\"metrics_params\":\"$METRICS_PARAMS\"}" +echo "{\"ok\":true,\"method\":\"script\",\"platform\":\"$PLATFORM\",\"content_id\":\"$CONTENT_ID\",\"metrics_params\":\"$METRICS_PARAMS\",\"deep_stored\":$([ -z "${DEEP_ARGS[*]}" ] && echo false || echo true)}" diff --git a/crews/main/skills/published-track/scripts/fetch-retro-data.ts b/crews/main/skills/published-track/scripts/fetch-retro-data.ts index 26cde788..030830b8 100644 --- a/crews/main/skills/published-track/scripts/fetch-retro-data.ts +++ b/crews/main/skills/published-track/scripts/fetch-retro-data.ts @@ -2,8 +2,10 @@ /** * fetch-retro-data.ts — 复盘数据抓取(第一层:纯 HTTP + cookie + 签名) * - * 这是复盘数据抓取的第一层,只拿基础互动指标(播放/点赞/评论数)。 - * 第二层(完播率/转粉率/评论内容等深度数据)通过 browser tool + evaluate + * 这是复盘数据抓取的第一层,拿基础互动指标(播放/点赞/评论数)+ 抖音创作侧深指标 + * (2026-09 起 douyin 走 creator item/list:view_count 播放量公开侧恒 0 仅创作侧可见, + * 另有完播率/封面 CTR 等 26 字段,视频+图文(note)作品通用;见 _shared/douyin-web.ts + * douyinCreatorItem)。其余深度数据(评论内容等)通过 browser tool + evaluate * CDP 拦截实现,不在此脚本中。 * * 签名方案复用: @@ -47,6 +49,8 @@ interface RetroResult { platform: string contentId: string stats: Record + /** 创作侧深指标(douyin item/list:完播率/封面 CTR 等,键为平台原名)。经 fetch-and-update 落 pub_douyin.deep_metrics(只存最新值)。 */ + deep?: Record comments: Array<{ cid: string; text: string; likeCount: number; userName: string }> error?: string msg?: string @@ -145,7 +149,9 @@ async function fetchDouyin(awemeId: string): Promise { // 视频详情(aweme/detail 接口)——只取数,不碰评论 // (参考 wiseflow4-pro douyin aweme_processor.__call__ → get_video_by_id → // update_douyin_aweme:读 statistics 的 digg_count/collect_count/comment_count/share_count。) - console.error(" → 调抖音 API 获取视频详情...") + // 图文(note)作品的 mid 同样走此端点。play_count 公开侧恒为 0(播放量仅创作者可见), + // 下方创作侧 lane 是播放量的唯一来源。 + console.error(" → 调抖音 API 获取作品详情...") try { const { status, data } = await douyinWebGet( "/aweme/v1/web/aweme/detail/", @@ -156,19 +162,62 @@ async function fetchDouyin(awemeId: string): Promise { const aweme = data?.aweme_detail if (aweme) { const stats = aweme.statistics || {} - result.stats = { - playCount: stats.play_count || 0, - likeCount: stats.digg_count || 0, - commentCount: stats.comment_count || 0, - shareCount: stats.share_count || 0, - collectCount: stats.collect_count || 0, + // 公开侧播放量不可用;其余指标仅接受明确返回的数值,缺失不补零。 + const mapping = { + digg_count: "likeCount", + comment_count: "commentCount", + share_count: "shareCount", + collect_count: "collectCount", } - console.error(` ✓ 播放 ${result.stats.playCount} / 点赞 ${result.stats.likeCount} / 评论 ${result.stats.commentCount}`) + for (const [key, target] of Object.entries(mapping)) { + const value = stats[key] + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + result.stats[target] = value + } + } + console.error(` ✓ 点赞 ${result.stats.likeCount} / 评论 ${result.stats.commentCount} / 分享 ${result.stats.shareCount}`) + } else { + console.error(` ⚠️ 作品详情接口返回 ${status} 但无 aweme_detail(cookie 可能失效)`) + } + } catch (e) { + console.error(` ⚠️ 作品详情获取失败: ${e}`) + } + + // 创作侧 item/list(借鉴 OpenCLI #2307):26 字段深指标,视频+图文都在; + // creator 域 cookie-only 无需 a_bogus。失败不影响公开侧数据(graceful 降级)。 + console.error(" → 调创作侧 item/list 获取深指标(含播放量)...") + try { + const { douyinCreatorItem } = await import("../../_shared/douyin-web.ts") + const item = await douyinCreatorItem(awemeId, cookieStr, ua) + if (item) { + const m = item.metrics + // 有常规列的指标全部写 stats,创作侧优先;其余指标才放 deep。 + // view_count 是播放量唯一来源;缺失字段保留公开侧结果,明确的 0 正常覆盖。 + const mapping: Record = { + view_count: "playCount", + like_count: "likeCount", + comment_count: "commentCount", + share_count: "shareCount", + favorite_count: "collectCount", + } + result.deep = {} + for (const [key, value] of Object.entries(m)) { + if (mapping[key]) { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + result.stats[mapping[key]] = value + } + } else { + result.deep[key] = value + } + } + console.error( + ` ✓ 播放 ${m.view_count ?? "?"} / 5s完播率 ${m.completion_rate_5s ?? "?"} / 2s跳出率 ${m.bounce_rate_2s ?? "?"} / 封面点击率 ${m.cover_click_rate ?? "?"}(审核 ${item.review ?? "?"})`, + ) } else { - console.error(` ⚠️ 视频详情接口返回 ${status} 但无 aweme_detail(cookie 可能失效)`) + console.error(" ⚠️ 创作侧列表未找到该作品(cookie 非本账号,或作品超出列表深度)——播放量/深指标缺失,公开侧数据不受影响") } } catch (e) { - console.error(` ⚠️ 视频详情获取失败: ${e}`) + console.error(` ⚠️ 创作侧深指标获取失败(不影响公开侧数据): ${e}`) } return result diff --git a/crews/main/skills/published-track/scripts/init-db.sh b/crews/main/skills/published-track/scripts/init-db.sh index 2282a8c1..01bddea7 100755 --- a/crews/main/skills/published-track/scripts/init-db.sh +++ b/crews/main/skills/published-track/scripts/init-db.sh @@ -130,6 +130,9 @@ CREATE TABLE IF NOT EXISTS pub_douyin ( likes INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, shares INTEGER DEFAULT 0, + deep_metrics TEXT, + deep_captured_at TEXT, + deep_source TEXT, favorites INTEGER DEFAULT 0, top_comment TEXT, notes TEXT, @@ -599,4 +602,14 @@ for table in $(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' fi done -echo '{"ok":true,"message":"published_track.db initialized (v3: dna_id + account + perf_evaluated)"}' +# ── 迁移:douyin 创作侧深指标列(deep_metrics / deep_captured_at / deep_source)── +# 只存最新值,不留历史快照(用户 2026-09-18 定调:不需要增长史)。 +# 其余平台接 deep 数据源(如 xhs-engagement)时按同款三列扩展。 +if [ "$(sqlite3 "$DB" "SELECT count(*) FROM pragma_table_info('pub_douyin') WHERE name='deep_metrics';")" = "0" ] \ + && [ "$(sqlite3 "$DB" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='pub_douyin';")" = "1" ]; then + sqlite3 "$DB" "ALTER TABLE pub_douyin ADD COLUMN deep_metrics TEXT;" + sqlite3 "$DB" "ALTER TABLE pub_douyin ADD COLUMN deep_captured_at TEXT;" + sqlite3 "$DB" "ALTER TABLE pub_douyin ADD COLUMN deep_source TEXT;" +fi + +echo '{"ok":true,"message":"published_track.db initialized (v4: douyin deep_metrics)"}' diff --git a/crews/main/skills/published-track/scripts/platform-status.py b/crews/main/skills/published-track/scripts/platform-status.py new file mode 100644 index 00000000..def33ec3 --- /dev/null +++ b/crews/main/skills/published-track/scripts/platform-status.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Read the existing calibration enabled flag without initializing state.""" +import argparse +import json +import os +from pathlib import Path + + +def platform_status(workspace, platform): + directory = workspace / platform / 'calibration' + path = directory / 'platform-state.json' + if not path.exists(): + path = directory / '.platform-state.json' + if not path.exists(): + return {'ok': True, 'platform': platform, 'enabled': False, 'reason': 'NOT_INITIALIZED'} + try: + state = json.loads(path.read_text()) + if not isinstance(state, dict) or not isinstance(state.get('enabled'), bool): + raise ValueError('enabled must be a boolean') + return {'ok': True, 'platform': platform, 'enabled': state['enabled']} + except (OSError, ValueError) as exc: + return {'ok': False, 'platform': platform, 'enabled': False, 'error': str(exc)} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--platform', required=True, choices=['douyin', 'xhs', 'wx_channel', 'wx_mp']) + args = parser.parse_args() + workspace = Path(os.path.abspath(Path(__file__).parent / '../../..')) + result = platform_status(workspace, args.platform) + print(json.dumps(result, ensure_ascii=False)) + return 0 if result['ok'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/crews/main/skills/published-track/scripts/update-metrics.sh b/crews/main/skills/published-track/scripts/update-metrics.sh index ce9eb90b..40431cf7 100755 --- a/crews/main/skills/published-track/scripts/update-metrics.sh +++ b/crews/main/skills/published-track/scripts/update-metrics.sh @@ -34,11 +34,14 @@ Required: write; use only when you intentionally want every row with that folder to receive the same metrics). -Metrics (at least one required): +Metrics (at least one of metrics / --deep-file required): -- A metric column to set (integer or text). --= Equivalent inline form. Valid columns depend on the platform table schema; an unknown column is rejected with the list of valid metric columns. + --deep-file Deep-metrics JSON file (single line) → deep_metrics column + (latest value only, no history). Written via sqlite readfile(). + --deep-source Data source tag stored in deep_source (e.g. douyin:creator_item_list). Examples: update-metrics.sh --platform xhs --id 10 --views 100 --likes 10 @@ -58,6 +61,9 @@ fi # Parse args PLATFORM="" SOURCE_FOLDER="" ROW_ID="" +# deep 指标走 JSON 文件而非 --col=value(JSON 进 shell 参数是引号地狱,readfile 免疫) +DEEP_FILE="" +DEEP_SOURCE="" # bash 3.2 兼容:不用关联数组,平行索引数组存 metric 键值;同名键后值覆盖 METRIC_KEYS=() METRIC_VALS=() @@ -79,6 +85,8 @@ while [[ $# -gt 0 ]]; do --platform) PLATFORM="$2"; shift 2 ;; --source-folder) SOURCE_FOLDER="$2"; shift 2 ;; --id) ROW_ID="$2"; shift 2 ;; + --deep-file) DEEP_FILE="$2"; shift 2 ;; + --deep-source) DEEP_SOURCE="$2"; shift 2 ;; --*=*) KEY="${1#--}" KEY="${KEY%%=*}" @@ -134,8 +142,8 @@ fi # Get valid columns for this table (exclude id, created_at) COLS=$(sqlite3 "$DB" "PRAGMA table_info($TABLE);" | awk -F'|' '{print $2}' | grep -v -E '^(id|created_at|source_folder|content_type|title|publish_date)$' | tr '\n' ' ') -# Build SET clause -if [ ${#METRIC_KEYS[@]} -eq 0 ]; then +# Build SET clause(deep-only 写入也算有效——无标量指标但有 --deep-file 时继续) +if [ ${#METRIC_KEYS[@]} -eq 0 ] && [ -z "$DEEP_FILE" ]; then echo '{"ok":false,"error":"no metrics provided to update"}' exit 1 fi @@ -159,6 +167,33 @@ SET_PARTS+=("updated_at=strftime('%Y-%m-%d %H:%M:%S','now','localtime')") SET_CLAUSE=$(IFS=','; echo "${SET_PARTS[*]}") -sqlite3 "$DB" "UPDATE $TABLE SET $SET_CLAUSE WHERE $WHERE_CLAUSE;" +if [ ${#METRIC_KEYS[@]} -gt 0 ]; then + sqlite3 "$DB" "UPDATE $TABLE SET $SET_CLAUSE WHERE $WHERE_CLAUSE;" +fi + +# ── deep 指标写入(JSON 文件 → deep_metrics 列,只存最新值)──────────────── +DEEP_UPDATED=false +if [ -n "$DEEP_FILE" ]; then + if [ ! -f "$DEEP_FILE" ]; then + echo "{\"ok\":false,\"error\":\"deep file not found: $DEEP_FILE\"}" + exit 1 + fi + # 路径拼进 SQL(readfile 参数),只放行安全字符 + if ! [[ "$DEEP_FILE" =~ ^[A-Za-z0-9_./-]+$ ]]; then + echo '{"ok":false,"error":"deep file path contains invalid characters"}' + exit 1 + fi + # 自愈补列(同 ensure_platform_table 模式:init-db 幂等,ALTER 有 has_col 守卫) + if [ "$(sqlite3 "$DB" "SELECT count(*) FROM pragma_table_info('$TABLE') WHERE name='deep_metrics';")" = "0" ]; then + bash "$(dirname "$0")/init-db.sh" >/dev/null 2>&1 || true + fi + if [ "$(sqlite3 "$DB" "SELECT count(*) FROM pragma_table_info('$TABLE') WHERE name='deep_metrics';")" = "0" ]; then + echo "{\"ok\":false,\"error\":\"deep columns not available in $TABLE (init-db self-heal failed)\",\"hint\":\"手动跑 init-db.sh 补列后重试\"}" + exit 1 + fi + # CAST(readfile() AS TEXT):readfile 返回 BLOB,不 cast 的话 -json 查询会渲染成 base64 + sqlite3 "$DB" "UPDATE $TABLE SET deep_metrics=CAST(readfile('$DEEP_FILE') AS TEXT), deep_captured_at=strftime('%Y-%m-%d %H:%M:%S','now','localtime'), deep_source='${DEEP_SOURCE:-unknown}' WHERE $WHERE_CLAUSE;" + DEEP_UPDATED=true +fi -echo "{\"ok\":true,\"table\":\"$TABLE\",\"located_by\":\"${LOCATE_KEY}\",\"updated_columns\":${#METRIC_KEYS[@]}}" +echo "{\"ok\":true,\"table\":\"$TABLE\",\"located_by\":\"${LOCATE_KEY}\",\"updated_columns\":${#METRIC_KEYS[@]},\"deep_updated\":$DEEP_UPDATED}" diff --git a/crews/main/skills/talking-head-cut/SKILL.md b/crews/main/skills/talking-head-cut/SKILL.md index cc7f9674..3c0e385f 100644 --- a/crews/main/skills/talking-head-cut/SKILL.md +++ b/crews/main/skills/talking-head-cut/SKILL.md @@ -158,13 +158,13 @@ video-review /highlight.mp4 | 依赖 | 来源 | 说明 | |------|------|------| | ffmpeg / ffprobe | 系统 | 抽 WAV、剪拼、concat | -| 火山引擎豆包语音极速版 | env `VOLC_ASR_*` | ASR 转写拿 word 级时间戳 | -| requests | 仓根 requirements.txt | 调火山 ASR HTTP API | +| 公共 ASR 路由 | env 任一组凭据 | 火山极速版(`VOLC_ASR_*`)→ 百炼业务空间 → 百炼 agent plan;拿 word 级时间戳 | +| requests | 仓根 requirements.txt | 调 ASR HTTP API | | `video-edit` 技能 | 同 workspace | Step 3 剪拼(apply-cut)+ 后续加 BGM(audio-mix) | | `video-review` 技能 | 公共 skills | Step 4 成片自检 | | `bgm-library` 技能 | 公共 skills | 加 BGM 时的曲源(ccMixter 免版税,免 key,优先于 aigc-video-gen music) | -**火山 ASR 凭证**:需 `VOLC_ASR_APP_ID` + `VOLC_ASR_ACCESS_KEY`(旧控制台双头)或 `VOLC_ASR_APP_KEY`(新控制台单头)。未配置时退出码 2 并提示走 viral-chaser 开通流程。 +**ASR 凭证**(任一组):火山 `VOLC_ASR_APP_ID` + `VOLC_ASR_ACCESS_KEY`(旧控制台双头)或 `VOLC_ASR_APP_KEY`(新控制台单头);百炼业务空间 `WORKSPACE_ID` + `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY`;百炼 agent plan `AWK_API_KEY`。全部未配置时退出码 2 并提示走 viral-chaser 开通流程。 --- diff --git a/crews/main/skills/talking-head-cut/scripts/cut_plan.py b/crews/main/skills/talking-head-cut/scripts/cut_plan.py index 848c8380..41f9d786 100644 --- a/crews/main/skills/talking-head-cut/scripts/cut_plan.py +++ b/crews/main/skills/talking-head-cut/scripts/cut_plan.py @@ -8,8 +8,9 @@ 流程: 1. ffmpeg 抽 16kHz mono WAV - 2. 调火山方舟豆包语音极速版(volc.bigasr.auc_turbo)拿 utterance + word 级时间戳 - (复用 viral-chaser 的鉴权约定:VOLC_ASR_APP_ID+VOLC_ASR_ACCESS_KEY 或 VOLC_ASR_APP_KEY) + 2. 调公共 ASR 路由(_shared/asr.py:火山极速版 → 百炼业务空间 → 百炼 agent plan) + 拿 utterance + word 级时间戳 + (凭据:VOLC_ASR_* 或 WORKSPACE_ID+MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY 或 AWK_API_KEY) 3. 多层检测(按 --mode 决定保留策略): - fillers:语气词清单匹配(zh: 嗯/呃/额/唔/哎/诶/欸;en: um/uh/uhm/er) - silence:word gap > --silence-gap 秒 @@ -23,7 +24,7 @@ 依赖: - ffmpeg/ffprobe(系统) - - 火山 ASR env(VOLC_ASR_*) + - ASR 凭据(VOLC_ASR_* / WORKSPACE_ID+MODELSTUDIO_API_KEY / AWK_API_KEY 任一组) - requests(Python 包,仓根 requirements.txt 已声明) 无第三方 ASR/VAD 包——不引入 faster-whisper / Silero VAD,与 main stdlib + ffmpeg 范式一致。 @@ -31,7 +32,7 @@ 退出码: 0 = 成功 1 = 参数错误 / 文件不存在 - 2 = 火山 ASR env 未配置(提示用户走 viral-chaser 开通流程) + 2 = ASR 凭据未配置(提示用户走 viral-chaser 开通流程配火山,或配百炼 key) 3 = ffmpeg/ffprobe 不存在 """ @@ -47,9 +48,9 @@ import uuid from pathlib import Path -# 注入 _shared 到 sys.path,复用公共火山 ASR 脚本(与 xhs-publish/scripts/publish_xhs.py 同范式) +# 注入 _shared 到 sys.path,复用公共 ASR 路由(与 xhs-publish/scripts/publish_xhs.py 同范式) sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared")) -from volc_asr import volc_asr # noqa: E402 +from asr import asr # noqa: E402 # 语气词清单 FILLERS_ZH = {"嗯", "呃", "额", "唔", "哎", "诶", "欸", "啊", "呀", "嘛", "呢", "吧"} @@ -338,8 +339,8 @@ def main() -> None: pass sys.exit(3) - # 2. 火山 ASR - asr_result = volc_asr(wav_path) + # 2. ASR 路由(火山 → 百炼业务空间 → 百炼 agent plan) + asr_result = asr(wav_path) try: os.unlink(wav_path) except OSError: diff --git a/crews/main/skills/video-edit/SKILL.md b/crews/main/skills/video-edit/SKILL.md index 775d090d..f9384191 100644 --- a/crews/main/skills/video-edit/SKILL.md +++ b/crews/main/skills/video-edit/SKILL.md @@ -142,7 +142,7 @@ video-edit audio-mix input.mp4 --narration speech.mp3 --bgm music.mp3 --output o 旁白音频的生成: 1. **优先使用 OpenClaw 内置 TTS 工具**(`tts_generate` 或 agent 内置语音合成能力) -2. 内置 TTS 不可用时,使用公共 `awk-tts` 技能(火山方舟豆包语音合成 2.0,要求环境变量已配置 `VOLC_TTS_*` 凭据) +2. 内置 TTS 不可用时,使用公共 `awk-tts` 技能(多供应商:火山豆包 2.0 → 百炼业务空间 → 百炼 agent plan,凭据在哪家走哪家) 3. 旁白时长必须与视频时长匹配(TTS 语速可微调以适配),混音前先核对两者时长 BGM(`--bgm` 文件)的来源: @@ -246,7 +246,7 @@ verdict=pass 才交付;fail 按 critical 项修复后重审;warn 向用户 - 竖屏封面 1080x1920 - 可以使用视频关键画面作为背景,但文字是必须元素 -使用公共 `siliconflow-img-gen` 技能制作封面,保存为 `/cover.jpg`。 +使用公共 `awk-img-gen` 技能制作封面,保存为 `/cover.jpg`。 > 仅对交付成片的项目做封面;中间加工产物(如只是帮用户给一段素材加 BGM)不需要封面。 diff --git a/crews/main/skills/viral-chaser/SKILL.md b/crews/main/skills/viral-chaser/SKILL.md index 8f87ed9f..38fd872c 100644 --- a/crews/main/skills/viral-chaser/SKILL.md +++ b/crews/main/skills/viral-chaser/SKILL.md @@ -8,13 +8,21 @@ metadata: bins: - node - ffmpeg - env: - - VOLC_ASR_APP_ID --- -## 🔑 前置:开通火山语音模型(仅首次) +## 🔑 前置:ASR 凭据(仅首次) -本技能的语音转写(ASR)使用**火山引擎豆包语音 · 录音文件极速版**(资源 ID `volc.bigasr.auc_turbo`)。即便账号已订购火山 Code Plan,语音模型仍需**单独开通**,否则调用会返回鉴权/权限错误。 +本技能的语音转写走公共 ASR 路由(`crews/main/skills/_shared/asr.py`),凭据在哪家走哪家: + +1. **火山录音文件极速版**(`VOLC_ASR_*`,优先)——需单独开通语音模型,见下 +2. **百炼业务空间**(`WORKSPACE_ID` + `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY`) +3. **百炼 agent plan**(`AWK_API_KEY`) + +三组任一组在环境里即可运行;都缺失时分析器退出码 2 并提示配置。 + +### 火山开通指引(选火山路线时) + +火山引擎豆包语音 · 录音文件极速版(资源 ID `volc.bigasr.auc_turbo`)。即便账号已订购火山 Code Plan,语音模型仍需**单独开通**,否则调用会返回鉴权/权限错误。 **判断是否已开通**:直接跑 Step 3 分析器,若 ASR 报错含 `status=45xxxxx` 或权限相关码,说明未开通,按下面流程开通一次即可。 @@ -144,6 +152,7 @@ The script outputs a **JSON object to stdout**. Read it and proceed with analysi **Exit codes:** - `0` = Success - `1` = Error(URL invalid / download failed),或 `SIGN_UNAVAILABLE`(签名缺 OFB_KEY,重登救不了,交 IT engineer 配凭证) +- `3` = `SECURITY_BLOCK`(小红书软风控)— 脚本已做一次冷却重试;停止本轮该平台采样,不立即重跑、不换 cookie、不重登 - `2` = `SESSION_EXPIRED`(cookie 失效)— 走 login-manager 重登(`login-manager --platform

` 导出+验证),重试一次 ### Step 3 — Read key frames (if available) @@ -234,7 +243,7 @@ Read: /ref//references/frames/frame_01_3s.jpg | 影视解说 / 剧情解说 + 反转植入(「万万没想到」式) | Content Producer `expert-video` → Reversal Ad workflow | | 口播类(真人口播出镜,或旁白 + 画面) | Content Producer `expert-video` → Narration Video workflow | | 一句文稿转视觉隐喻的纸拼贴动画 | Content Producer `expert-video` → Collage B-roll workflow | -| 纯 AIGC 动画 / 剧情短片 / 蒙太奇(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**(CP 按其通用制作流程做,Stage 1 定档位:narrative / motion / montage) | +| 纯 AIGC 动画 / 剧情短片 / 蒙太奇(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**(CP 按其通用制作流程做,据创意自定叙事 / 动效 / 蒙太奇手法) | | 已有素材简单拼接、加旁白、烧字幕 | main `video-edit` | | 已有真人口播素材去口气词、剪高光 | main `talking-head-cut` | | 产品操作录屏 | main `ui-demo` | diff --git a/crews/main/skills/viral-chaser/scripts/transcriber.ts b/crews/main/skills/viral-chaser/scripts/transcriber.ts index 9ec81f8c..f8e76d2e 100644 --- a/crews/main/skills/viral-chaser/scripts/transcriber.ts +++ b/crews/main/skills/viral-chaser/scripts/transcriber.ts @@ -1,28 +1,22 @@ #!/usr/bin/env -S node --experimental-strip-types /** - * transcriber.ts — ASR transcription via 火山引擎豆包语音(录音文件极速版) + * transcriber.ts — ASR transcription via 公共 ASR 路由(_shared/asr.py) * - * 接口:POST https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash - * 资源 ID:volc.bigasr.auc_turbo(需在火山控制台「开通管理 → 语音模型」开通) + * 供应商优先级(2026-09 拍板): + * 1. 火山录音文件极速版(volc.bigasr.auc_turbo,VOLC_ASR_* 凭据) + * 2. 百炼业务空间(qwen-audio-3.0-asr-flash,WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY) + * 3. 百炼 agent plan(qwen-audio-3.0-asr-flash,AWK_API_KEY,token-plan 端点) + * 某家失败自动落下一家;协议细节见 _shared/volc_asr.py 与 _shared/bailian_asr.py。 * - * 选型说明:viral-chaser 的输入是本地 audio.wav(16kHz mono,≤10min), - * 极速版支持 audio.data(base64)直传本地文件,一次请求即返回,无需对象 - * 存储/公网 URL,且原生返回 utterances 带 start_time/end_time(毫秒)和 - * word 级时间戳——正好替代原先 SiliconFlow SenseVoiceSmall 无时间戳、 - * 靠字数比例估算的方案。标准版 2.0(volc.seedasr.auc)单价更低但只接受 - * audio.url,需自备 TOS 托管,未采用。 - * - * 鉴权:兼容新旧控制台(二选一,优先旧控制台双头)。 - * - 旧控制台双头:VOLC_ASR_APP_ID(数字 APP ID)+ VOLC_ASR_ACCESS_KEY(Access Token) - * → X-Api-App-Key=APP_ID, X-Api-Access-Key=Token, user.uid=APP_ID - * - 新控制台单头:VOLC_ASR_APP_KEY(APP Key)→ X-Api-Key=APP_KEY, user.uid=APP_KEY - * 注意:旧控制台 X-Api-App-Key 要的是数字 APP ID,不是 Secret Key/APP Key - * (把 Secret Key 塞进 X-Api-App-Key 会得到 45000010 request and grant appid mismatch)。 + * 选型说明:viral-chaser 的输入是本地 audio.wav(16kHz mono,≤10min)。 + * 两家都支持 base64 直传本地文件并返回 word 级时间戳(百炼超 10MB 自动 + * ffmpeg 压成 32kbps mp3 再传),无需对象存储/公网 URL。百炼把整段并成 + * 单 sentence,_shared/bailian_asr.py 按词级标点切回 utterances,与火山同构。 * * 实现说明:沿用 xhs.ts 同一模式(python3 -c 内联脚本调 requests),避免 * Node fetch/FormData 在部分环境的兼容异常。 * - * 注意:保留 synthesizeSegments 作为兜底——正常情况下火山会返回真实 + * 注意:保留 synthesizeSegments 作为兜底——正常情况下路由会返回真实 * utterances,estimated=false;仅当接口异常未返回 utterances 时才按音频 * 时长估算,estimated=true。 */ @@ -94,16 +88,16 @@ function synthesizeSegments(text: string, durationSeconds: number): TranscriptSe return segs } -// 调公共 _shared/volc_asr.py(与 talking-head-cut / video-producer narration-align 共一份逻辑)。 -// 范式:python3 -c 加载 _shared 到 sys.path,import volc_asr,调它拿 {ok, text, utterances, words}, +// 调公共 _shared/asr.py 路由(与 talking-head-cut / video-producer narration-align 共一份逻辑)。 +// 范式:python3 -c 加载 _shared 到 sys.path,import asr,调它拿 {ok, text, utterances, words}, // 输出 JSON 到 stdout 供 Node 解析。_shared 路径按本剧本位置算(crews/main/skills/_shared)。 const SHARED_DIR = fileURLToPath(new URL("../../_shared/", import.meta.url)) const PYTHON_CALL = ` import json, os, sys sys.path.insert(0, ${JSON.stringify(SHARED_DIR)}) -from volc_asr import volc_asr, load_env_file +from asr import asr, load_env_file load_env_file() -result = volc_asr(sys.argv[1]) +result = asr(sys.argv[1]) # 降级到 utterance 级供旧 TranscriptResult 结构兼容(viral-chaser 只用 utterance 级) segs = [] if result.get("ok"): @@ -117,10 +111,11 @@ export async function transcribeAudio(audioPath: string, durationSeconds = 0): P throw new Error(`音频文件不存在: ${audioPath}`) } - // 极速版硬限 100MB;本地 audio.wav(16kHz mono ≤10min)约 19MB,远低于上限。 + // 兜底上限 100MB(火山极速版硬限;百炼路径 >10MB 会在 _shared 内自动转码压缩)。 + // 本地 audio.wav(16kHz mono ≤10min)约 19MB,正常远低于上限。 const sizeMb = statSync(audioPath).size / (1024 * 1024) if (sizeMb > 100) { - throw new Error(`音频文件过大 (${sizeMb.toFixed(1)}MB),火山极速版上限 100MB`) + throw new Error(`音频文件过大 (${sizeMb.toFixed(1)}MB),超出 ASR 输入上限 100MB`) } const { stdout } = await execFileAsync( diff --git a/crews/main/skills/viral-chaser/scripts/viral_chaser.ts b/crews/main/skills/viral-chaser/scripts/viral_chaser.ts index 7aafb9bd..7f348dfb 100644 --- a/crews/main/skills/viral-chaser/scripts/viral_chaser.ts +++ b/crews/main/skills/viral-chaser/scripts/viral_chaser.ts @@ -9,6 +9,7 @@ * 0 Success — prints JSON result to stdout * 1 General error (URL invalid, download failed, etc.) * 2 Cookie invalid / not logged in → caller should run login-manager + * 3 XHS SECURITY_BLOCK after one cooldown retry; stop, do not re-login */ import { mkdirSync, existsSync, rmSync } from "fs" @@ -22,6 +23,7 @@ import type { SessionData } from "./session.ts" import { checkSession } from "../../_shared/check-session.ts" import { getDouyinVideo } from "./platforms/douyin.ts" import { getBilibiliVideo } from "./platforms/bilibili.ts" +import { XhsSecurityBlockError } from "../../_shared/xhs-html-note.ts" import { getXhsVideo } from "./platforms/xhs.ts" import { downloadVideo } from "./downloader.ts" import { extractAudio } from "./audio_extractor.ts" @@ -189,6 +191,10 @@ async function main(): Promise { errExit(`不支持的平台: ${platform}`) } } catch (e) { + if (e instanceof XhsSecurityBlockError) { + printJson({ ok: false, error: "SECURITY_BLOCK", platform: "xhs", reason: e.message }) + process.exit(3) + } const msg = (e as Error).message if (msg.includes("cookie") || msg.includes("失效") || msg.includes("auth")) { process.stderr.write(JSON.stringify({ ok: false, error: "SESSION_EXPIRED" }) + "\n") diff --git a/docs/ai-catchup-2026-07-opencli-v1.8.6.md b/docs/ai-catchup-2026-07-opencli-v1.8.6.md index 681d0205..6230679d 100644 --- a/docs/ai-catchup-2026-07-opencli-v1.8.6.md +++ b/docs/ai-catchup-2026-07-opencli-v1.8.6.md @@ -146,7 +146,7 @@ def camoufox_eval_with_reliability( 1. 在 `crews/main/skills/login-manager/scripts/login_manager.py`(已是 Python 核心)加 reliable_eval helper 2. `crews/main/skills/twitter-interact/scripts/twitter_interact.py`(已有 27 单测)替换所有 camoufox_eval 3. 加 `journal` 字段到 `cmd_*.json` 输出,部署后做排故更清晰 -4. `crews/main/skills/douyin-publish/scripts/publish_douyin.py` + `wx-mp-engagement/scripts/fetch_engagement.py` 同样替换 +4. `crews/main/skills/douyin-video-publish/scripts/publish_douyin.py` + `wx-mp-engagement/scripts/fetch_engagement.py` 同样替换 ### 2.2 中等相关:v1.8.6 exactly-once command transport (journal) @@ -185,7 +185,7 @@ def camoufox_eval_with_reliability( **本仓**: - `crews/main/skills/twitter-interact/scripts/twitter_interact.py` 主入口已有 try/except → sys.exit(1) - login-manager 同 -- douyin-publish 同 +- douyin-video-publish 同 - **本仓已基本做对**(exit code 1 + stderr message) **借鉴**: diff --git a/docs/browser-stack-replacement-spec-2026-07.md b/docs/browser-stack-replacement-spec-2026-07.md index 06f1fa4c..d155afd5 100644 --- a/docs/browser-stack-replacement-spec-2026-07.md +++ b/docs/browser-stack-replacement-spec-2026-07.md @@ -50,7 +50,7 @@ | 项 | 说明 | |----|------| -| **upload 命令** | 上游无 upload 命令(命令集:open/back/forward/reload/url/title/close/snapshot/click/fill/type/select/check/hover/press/text/eval/screenshot/pdf/scroll/wait/tabs/switch/close-tab/sessions/cookies/install)。fork 补 `upload `,走 Playwright `setInputFiles`。发布类技能(douyin-publish / xhs-publish / weibo-publish / zhihu-publish / wechat-channels-publish / youtube-publish)依赖此 | +| **upload 命令** | 上游无 upload 命令(命令集:open/back/forward/reload/url/title/close/snapshot/click/fill/type/select/check/hover/press/text/eval/screenshot/pdf/scroll/wait/tabs/switch/close-tab/sessions/cookies/install)。fork 补 `upload `,走 Playwright `setInputFiles`。发布类技能(douyin-video-publish / xhs-publish / weibo-publish / zhihu-publish / wechat-channels-publish / youtube-publish)依赖此 | | **fail-first 队列** | 同 session 并发不是良性失败而是互相踩(`server.js:71` 无锁 + `commands.js:24` 共享 page.goto)。fork 内置 **fail-first 队列**:同 session 已有命令在跑时,新命令直接 fail,**失败返回文本写清原因和指导**("session 正忙,请等待当前操作完成后再试")。agent 读到 fail 文本知道发生了什么、该干什么(等待重试)。不自动排队、不自动等待——避免隐藏排队语义 | | **identity export 命令** | fork 加 `identity export` 命令,导出当前 session 的 UA / 指纹摘要(供脚本侧导入 UA,对应原则 4)。与 `cookies export` 对称使用 | @@ -151,7 +151,7 @@ spike 文档 L30-33 已设计: | `xhs-content-ops` | 适配修改后的login-manager中央cookie格式(`xhs-browse`),尤其是导入Cookie的时候,要同时导入UA。| | `xhs-publish` | 适配修改后的login-manager中央cookie格式(`xhs-publish`),尤其是导入Cookie的时候,要同时导入UA。 | | `xhs-interact` | forked cli 持久化 session `xhs` + upload;有头登录 | -| `douyin-publish` | 由脚本方案改为浏览器自动化方案:forked cli 持久化 session `douyin` + upload;有头登录 | +| `douyin-video-publish` | 由脚本方案改为浏览器自动化方案:forked cli 持久化 session `douyin` + upload;有头登录 | | `weibo-publish` | forked cli 持久化 session `weibo` + upload;有头登录 | | `zhihu-publish` | forked cli 持久化 session `zhihu` + upload;有头登录 | | `wechat-channels-publish` | forked cli 持久化 session `wechat-channel` + upload;无头截图扫码登录(截 QR PNG 发用户扫码,渲染失败才 `--headed` 兜底) | diff --git a/docs/d21-symlink-skill.md b/docs/d21-symlink-skill.md index 06c5fdb1..244788b5 100644 --- a/docs/d21-symlink-skill.md +++ b/docs/d21-symlink-skill.md @@ -132,21 +132,21 @@ login-manager check douyin # wrapper 在 PATH 中 | email-ops | scripts/send_email.py | wrapper → py | | pexels-footage | scripts/pexels_search.py | wrapper → py | | pixabay-footage | scripts/pixabay_search.py | wrapper → py | -| siliconflow-img-gen | scripts/gen.py | wrapper → py | +| awk-img-gen | scripts/gen.py | wrapper → py | | wxwork-drive | scripts/drive.py | wrapper → py | | youtube-publish | scripts/publish_youtube.py | wrapper → py | | bilibili-publish | scripts/publish_bilibili.py | wrapper → py | | design-system-picker | scripts/pick.sh | wrapper → sh | | init-workspace | scripts/init.sh | wrapper → sh | | ~~manim-explainer~~ | scripts/render-manim.sh | wrapper → sh(**2026-09-10 技能删除**,wrapper 与 bin 软链由 `expose_skill_wrappers` 的悬挂清理回收) | -| siliconflow-tts | scripts/tts.py | wrapper → py | -| siliconflow-video-gen | scripts/gen.py | wrapper → py | +| awk-tts | scripts/tts.py | wrapper → py | +| ~~siliconflow-video-gen~~ | scripts/gen.py | wrapper → py(**已删除**,CP 视频生成统一走公共 `aigc-video-gen`) | | awada-channel-setup | scripts/apply-awada-config.py | wrapper → py | | icp-exemption | scripts/generate_pdf.py | wrapper → py | | icp-filing | scripts/icp.sh | wrapper → sh | | exp-invite | scripts/invite.sh | wrapper → sh | | proactive-send | scripts/send.sh | wrapper → sh → mjs | -| douyin-publish | scripts/publish_douyin.sh | wrapper → sh → py(scripts 里已有内部 wrapper,顶层只多一跳)| +| douyin-video-publish | scripts/publish_douyin.sh | wrapper → sh → py(scripts 里已有内部 wrapper,顶层只多一跳)| | twitter-interact | scripts/twitter_interact.sh | wrapper → sh → py | | wx-mp-engagement | scripts/wx-mp-engagement.sh | wrapper → sh → py | | wx-mp-hunter | scripts/wx-mp-hunter.sh | wrapper → sh → ts | @@ -260,7 +260,7 @@ dev plan §Phase 7 续 写"验收": |-------|-------------------|------------------|--------------| | `crews/sales-cs/skills/exp-invite` | `scripts/invite.sh`(不变,已是真脚本) | —(无引导壳) | `scripts/invite.sh` | | `crews/sales-cs/skills/proactive-send` | `node scripts/send.mjs` | `scripts/send.sh` | `scripts/send.mjs` | -| `crews/main/skills/douyin-publish` | `python3 scripts/publish_douyin.py` | `scripts/publish_douyin.sh` | `scripts/publish_douyin.py` | +| `crews/main/skills/douyin-video-publish` | `python3 scripts/publish_douyin.py` | `scripts/publish_douyin.sh` | `scripts/publish_douyin.py` | | `crews/main/skills/wx-mp-hunter` | `node --experimental-strip-types scripts/wx_mp_hunter.ts` | `scripts/wx-mp-hunter.sh` | `scripts/wx_mp_hunter.ts` | | `crews/main/skills/wx-mp-engagement` | `python3 scripts/fetch_engagement.py` | `scripts/wx-mp-engagement.sh` | `scripts/fetch_engagement.py` | @@ -274,7 +274,7 @@ dev plan §Phase 7 续 写"验收": 1. `crews/sales-cs/skills/exp-invite` — `./skills/exp-invite/scripts/invite.sh` → `exp-invite` 2. `crews/sales-cs/skills/proactive-send` — `./skills/proactive-send/scripts/send.sh` → `proactive-send` -3. `crews/main/skills/douyin-publish` — `python3 ./skills/.../publish_douyin.py` → `douyin-publish` +3. `crews/main/skills/douyin-video-publish` — `python3 ./skills/.../publish_douyin.py` → `douyin-video-publish` 4. `crews/main/skills/wx-mp-hunter` — 混用 `./scripts/wx-mp-hunter.sh` + 绝对路径 → `wx-mp-hunter` **B. 15 个已配 wrapper 但 SKILL.md 未更新的 skill**(仅改 SKILL.md 示例为 PATH 风格,不动 wrapper): @@ -282,7 +282,7 @@ dev plan §Phase 7 续 写"验收": 5. `skills/email-ops` 6. `skills/pexels-footage` 7. `skills/pixabay-footage` -8. `skills/siliconflow-img-gen` +8. `skills/awk-img-gen` 9. `skills/wxwork-drive` 10. `crews/main/skills/xhs-publish` 11. `crews/main/skills/xhs-content-ops` diff --git a/docs/expert-pack-dna-architecture.md b/docs/expert-pack-dna-architecture.md index 94a8047e..1ed25598 100644 --- a/docs/expert-pack-dna-architecture.md +++ b/docs/expert-pack-dna-architecture.md @@ -145,7 +145,7 @@ crews/main/ 命名约定: -- 专家包统一加 `expert-` 前缀,与「操作型技能」(如 `douyin-publish`)区分。 +- 专家包统一加 `expert-` 前缀,与「操作型技能」(如 `douyin-video-publish`)区分。 - 专家包内不使用 `AGENTS.md` 作为文件名(避免与 workspace bootstrap 文件混淆,也避免被误解为会被自动注入)。 - 专家包内不保存可变 DNA。运行期生成的 DNA report、DNA 文档和 DNA template 一律写入 Workspace 的 `/dna//`;专家包只保留方法论、框架和工具。 - 非内容平台专家包(`expert-bd` / `expert-ir`,2026-08-27 落地)没有 DNA 与数据复盘概念:不配 style-profiler,也不要求 4.7 的 6 类 workflow 基线集;workflow 按业务场景组织(如 Lead Hunting / Investor Pipeline),运行期数据只有 Workspace `db/` 下的 SQLite 库。其余分层原则同样适用:薄根 `SKILL.md`(入口 + 路由)+ `workflows/*.md`(场景编排)+ `tools/`(原子技能收纳,SKILL.md 瘦身为工具说明书)+ 顶层 `.sh` wrapper 暴露到 PATH(`skill-wrappers.sh` 的 `*/tools/*/` 扫描层)。 @@ -413,7 +413,7 @@ crews//skills/expert-/tools/-style-profiler/ 3. **子模块不是独立 DNA**:口播文案子模块(`narration-script`,参考微信的起承转合)只在口播类作品启用,用于指导 main 写同类型视频的口播文案;账号运营子模块(`account-bio`、`content-mix-cadence`)只在样本来自对标账号批量提取时填写,**只写进 DNA 文档、不进 template**。 4. **视频 DNA 不含创作细节**:不写脚本结构、逐句台词、镜头表、转场与编码参数。视频类 template = **Brief.md 正文模板 + 口播文案模板(可选)**;图文类 template = 图文写作模板。 -`video-form`(视频内容形态:口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场 / 录屏 / 图文卡片)必须聚合成明确的**制作指向**,且只能写真实存在的资源名:Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程 + Stage 1 定档位),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`)。Brief 的 `workflow` 字段据此填写。 +`video-form`(视频内容形态:口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场 / 录屏 / 图文卡片)必须聚合成明确的**制作指向**,且只能写真实存在的资源名:Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程据创意自定手法),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`)。Brief 的 `workflow` 字段据此填写。 采样侧配套:`viral-chaser` 的输出必须够喂这套框架——视频 meta(时长、宽高与横竖屏、发布时间、作者与简介、话题标签、互动数据)、覆盖全片的关键帧(含 25%/50%/63%/75%/90% 比例点,反转点通常在 55%-76%)、按时间占比的结构拆解与反转点位置、内容形态判定与制作指向,以及可直接喂 profiler 的 DNA 样本文字稿格式。 @@ -479,7 +479,7 @@ crews/content-producer/ - **workflow 的语义随 crew 变**:平台运营包里 workflow = 业务场景(起号 / 生产 / 复盘);content-producer 里 workflow = **某一类型视频 / 某一类设计任务怎么做**。制作流程本身高度程式化、跨类型一致,写在包根 `SKILL.md`(阶段链、两闸门、护栏、Stage 12 工具箱);类型差异(叙事套路、素材来源、声画组织方式)才写进 workflow。 - **原技能整体降级为 tool**:`video-producer` / `collage-broll` / `design-full` 从路由面消失,成为包内工具;wrapper 名与子命令不变(靠 `skill-wrappers.sh` 的 `*/tools/*/` 扫描层暴露),调用方零改动。 - **`manim-explainer` 删除**:能力已被 `expert-video` 的通用制作流程与 AIGC 动画路径覆盖。 -- **原「Pipeline 机制」废除**:Brief 字段从 `pipeline` 改为 `workflow`;不存在「viral-chaser 报告输入」阶段(追爆拆解是 main 的采样动作,CP 只吃 Brief),Stage 1 由 `intent-router` 承担定档位,`reference-concepts` 降为可选工具(仅直接对接用户模式下用户给了参考拆解报告时用)。 +- **原「Pipeline 机制」废除**:Brief 字段从 `pipeline` 改为 `workflow`;不存在「viral-chaser 报告输入」阶段(追爆拆解是 main 的采样动作,CP 只吃 Brief),intent-router 档位分类已退役,叙事 / 动效 / 蒙太奇手法由 CP 据创意自定,`reference-concepts` 降为可选工具(仅直接对接用户模式下用户给了参考拆解报告时用)。 - **两种工作模式**:A 作为 main 的 subagent(甲方 = main,Brief 已确认,不重开需求讨论);B 直接对接用户(甲方 = 用户,可能不专业,先引导确认 Brief、落实素材位置与存在性,口播文案代拟需用户确认)。两种模式都坚持乙方角色、都自建工作区。 - **运行期数据只写 CP 自己的 Workspace**:`output_videos//`(视频)与 `design_assets/YYYY-MM-DD-<任务名>/`(设计);不写进 main 的平台目录,也不让 main 代建。 diff --git a/docs/platform-login-and-browser-spec.md b/docs/platform-login-and-browser-spec.md index 3b215eec..7baa1ad0 100644 --- a/docs/platform-login-and-browser-spec.md +++ b/docs/platform-login-and-browser-spec.md @@ -40,7 +40,7 @@ login-manager 只管以下 5 个平台,**其他平台完全不涉及**: | `weixin-channel` | `wechat-channels-publish` | — | 视频号,与公众号独立;不导出 Cookie | | `weibo` | `weibo-publish` | — | 不导出 Cookie | | `twitter` | `twitter-post`、`twitter-interact` | **共用** | 不导出 Cookie | -| `douyin` | `douyin-publish`、`viral-chaser`、`published-track` | — | login-manager 导出 Cookie 供脚本类下游(viral-chaser / published-track)消费;`douyin-publish` 自身不吃 Cookie,纯浏览器操作 | +| `douyin` | `douyin-video-publish`、`viral-chaser`、`published-track` | — | login-manager 导出 Cookie 供脚本类下游(viral-chaser / published-track)消费;`douyin-video-publish` 自身不吃 Cookie,纯浏览器操作 | | `bilibili` | `viral-chaser`、`published-track` | — | 导出 Cookie(目前无发布/浏览/互动技能) | | `kuaishou` | `published-track` | — | 导出 Cookie(目前无发布/浏览/互动技能) | @@ -94,7 +94,7 @@ camoufox-cli --session wx_mp --persistent --json identity export ~/.openclaw/log **关键**:所有消费方**同时导入 Cookie 和 UA**——同一指纹下的 Cookie 才不会被风控错配(spec §8,2026-06-29 CDP 注入 22 cookie 触发风控的教训)。 -> **`douyin-publish` 不在本表**——它是**纯浏览器操作**技能(形态仿 `wechat-channels-publish`),自身不吃中央 Cookie。它的探活/有头登录/导出 Cookie+UA 全交 `login-manager` 负责,导出的 `douyin.json` + `douyin.ua.json` 仅供本表中的脚本类下游(`viral-chaser` / `published-track`)消费。`douyin-publish` 自身复用 login-manager 留下的持久化 session `douyin` 做浏览器发布操作,**严禁 `cookies import`**。 +> **`douyin-video-publish` 不在本表**——它是**纯浏览器操作**技能(形态仿 `wechat-channels-publish`),自身不吃中央 Cookie。它的探活/有头登录/导出 Cookie+UA 全交 `login-manager` 负责,导出的 `douyin.json` + `douyin.ua.json` 仅供本表中的脚本类下游(`viral-chaser` / `published-track`)消费。`douyin-video-publish` 自身复用 login-manager 留下的持久化 session `douyin` 做浏览器发布操作,**严禁 `cookies import`**。 **xhs 双 Cookie 流向值得专门留意**: - `xhs-browse.json` 被 `xhs-content-ops` + `published-track` + `viral-chaser` **三方**消费 @@ -110,7 +110,7 @@ camoufox-cli --session wx_mp --persistent --json identity export ~/.openclaw/log - `wechat-channels-publish`(session `weixin-channel`,与公众号独立) - `weibo-publish`(session `weibo`) - `zhihu-publish`(session `zhihu`) -- `douyin-publish`(session `douyin`,**特档**:见下方专门说明) +- `douyin-video-publish`(session `douyin`,**特档**:见下方专门说明) **纠正要点**(每个技能 SKILL.md 都要写明): 1. **优先使用 camoufox-cli �持久化 session**,登录态在 session profile 里,**除非用户有明确要求别的**才走其他方案。 @@ -120,25 +120,25 @@ camoufox-cli --session wx_mp --persistent --json identity export ~/.openclaw/log 注:`wx-mp-hunter` + `wx-mp-engagement` 虽然也共用 session + 部分导出 Cookie,但归 §4 wx_mp 特例管,不在本节简单话术纠正清单里。 -### 6.1 douyin-publish 特档(需要 login-manager 探活/有头登录/导出,但自身不吃 Cookie) +### 6.1 douyin-video-publish 特档(需要 login-manager 探活/有头登录/导出,但自身不吃 Cookie) -`douyin-publish` 与上面 5 个不导出 Cookie 的技能**同构**——纯浏览器操作,自身不吃 Cookie、严禁 `cookies import`,形态仿 `wechat-channels-publish`。但有一个关键差异让它单独成档: +`douyin-video-publish` 与上面 5 个不导出 Cookie 的技能**同构**——纯浏览器操作,自身不吃 Cookie、严禁 `cookies import`,形态仿 `wechat-channels-publish`。但有一个关键差异让它单独成档: -| 维度 | 不导出 Cookie 的 5 技能(§6 主清单) | `douyin-publish` | +| 维度 | 不导出 Cookie 的 5 技能(§6 主清单) | `douyin-video-publish` | |------|----------------------------------|------------------| | 自身吃 Cookie | 否 | 否 | | 自管探活 + 登录 | 是(写在 SKILL.md 里,与 login-manager 无关) | **否——探活/有头登录/导出全交 login-manager** | | 导出 Cookie+UA | 不导出 | **由 login-manager 导出**,落 `~/.openclaw/logins/douyin.json` + `~/.openclaw/logins/douyin.ua.json` | -| 导出的用途 | — | 供**脚本类下游**消费(`viral-chaser` / `published-track`);**douyin-publish 自身不用** | +| 导出的用途 | — | 供**脚本类下游**消费(`viral-chaser` / `published-track`);**douyin-video-publish 自身不用** | | 登录模式 | 各自有头/无头 QR | **强制有头手动**(手机号+验证码 / 抖音 APP 扫码,login-manager §2 5 平台统一有头) | -**为什么 douyin-publish 不归 §6 主清单的「与 login-manager 完全无关」一档**:它需要 login-manager 帮它准备持久化 session(探活 + 有头登录 + 导出 Cookie+UA),自己**没有** login 子命令、**不**自管探活。但导出的 Cookie 它自己也不读——浏览器操作严禁 `cookies import`,session 内的登录态 + 指纹冻结就位即可做发布操作。 +**为什么 douyin-video-publish 不归 §6 主清单的「与 login-manager 完全无关」一档**:它需要 login-manager 帮它准备持久化 session(探活 + 有头登录 + 导出 Cookie+UA),自己**没有** login 子命令、**不**自管探活。但导出的 Cookie 它自己也不读——浏览器操作严禁 `cookies import`,session 内的登录态 + 指纹冻结就位即可做发布操作。 -**douyin-publish SKILL.md 必须写明**: +**douyin-video-publish SKILL.md 必须写明**: 1. **形态仿 `wechat-channels-publish`**:纯浏览器操作方案,走 camoufox-cli 持久化 session `douyin`(一个且只有一个持久化 session,fail-first 队列)。 2. **探活 / 有头登录 / 导出 Cookie+UA 全交 login-manager**——本 skill 不自管,不调用 `cookies export` / `identity export` / `cookies import`。 3. **自身不吃 Cookie**:发布脚本直接复用 login-manager 准备好的持久化 session `douyin`(`--session douyin --persistent`),不开临时 session、不 import cookie。 -4. **导出的 Cookie+UA 落中央存储仅供脚本类下游消费**(`viral-chaser` / `published-track`)——douyin-publish 自身不读这两个文件。 +4. **导出的 Cookie+UA 落中央存储仅供脚本类下游消费**(`viral-chaser` / `published-track`)——douyin-video-publish 自身不读这两个文件。 5. **发布任务跑完即 close 持久化 session `douyin`**——登录态在磁盘 profile,不留进程占内存,下次发布 `--session douyin --persistent` 重起无头即恢复;只在 session 卡死时 `camoufox-cli --session douyin --json close` teardown。 6. 子命令清单**无 `login`**:`upload` / `fill` / `publish` / `get-link` / `cleanup` / `run`(run 一键跑 upload → fill → publish → get-link,**不**自管探活)。 @@ -226,7 +226,7 @@ camoufox-cli --session wx_mp --persistent --json identity export ~/.openclaw/log 7. **published-track 中 xhs 取数重构**(§9):做 fetch-xhs-with-xsec.ts,整合拿映射+抓数。 8. **HEARTBEAT.md 简化**:删 xhs 那段 CDP 描述,指向新脚本。 9. **viral-chaser/scripts 核实+补齐**(§10):UA 同步导入全链路核实 + Platform 类型补齐。 -10. **douyin-publish 重构**(§6.1):之前误把它定位为「导出 Cookie 自己吃」一档(同 xhs-publish 模式),导致昨天改造走错。实际它**与 wechat-channels-publish 同构**——纯浏览器操作,自身不吃 Cookie、严禁 `cookies import`。差别在于:探活/有头登录/导出 Cookie+UA 全交 login-manager(供脚本类下游 viral-chaser / published-track 消费,douyin-publish 自身不读)。脚本删 `login` 子命令 + `login_manager_check` 自管探活;SKILL.md 重写职责划分。 +10. **douyin-video-publish 重构**(§6.1):之前误把它定位为「导出 Cookie 自己吃」一档(同 xhs-publish 模式),导致昨天改造走错。实际它**与 wechat-channels-publish 同构**——纯浏览器操作,自身不吃 Cookie、严禁 `cookies import`。差别在于:探活/有头登录/导出 Cookie+UA 全交 login-manager(供脚本类下游 viral-chaser / published-track 消费,douyin-video-publish 自身不读)。脚本删 `login` 子命令 + `login_manager_check` 自管探活;SKILL.md 重写职责划分。 11. **xhs-publish / xhs-content-ops 复核**:现状已对齐新体系,最后扫一遍确认话术与 login-manager SKILL.md 新版一致。 ## 12. 附:核实清单(重构时必查) @@ -240,5 +240,5 @@ camoufox-cli --session wx_mp --persistent --json identity export ~/.openclaw/log - [ ] HEARTBEAT.md:xhs CDP 段删,指向新脚本 - [ ] viral-chaser/scripts:UA 同步导入全链路;Platform 类型补齐 - [ ] 不导出 Cookie 的 5 技能:话术按 §6 改;共享 session 名约定(twitter / weixin-channel 等)写清 -- [ ] douyin-publish(§6.1):纯浏览器操作话术(仿 wechat-channels-publish);探活/有头登录/导出交 login-manager;脚本无 `login` 子命令、无 `login_manager_check`;自身不吃 Cookie 严禁 `cookies import` +- [ ] douyin-video-publish(§6.1):纯浏览器操作话术(仿 wechat-channels-publish);探活/有头登录/导出交 login-manager;脚本无 `login` 子命令、无 `login_manager_check`;自身不吃 Cookie 严禁 `cookies import` - [ ] xhs-publish / xhs-content-ops:话术复核与新版 login-manager 一致 diff --git a/docs/video-capability-replanning-2026-07-25.md b/docs/video-capability-replanning-2026-07-25.md index 5b235715..a0710135 100644 --- a/docs/video-capability-replanning-2026-07-25.md +++ b/docs/video-capability-replanning-2026-07-25.md @@ -70,7 +70,7 @@ | `bilibili-publish` | B 站上传 | ⚠️ 同上 | | `viral-chaser` | 下载分析抖音/B 站/小红书爆款视频,**仅产出追爆报告**,视频生产需另行委托 content-producer | ⚠️ main 的 viral-chaser 与 content-producer 的衔接关系,重构后需重新明确 | -main agent 其余发布/运营技能(douyin-publish / wechat-channels-publish / weibo-publish / wx-mp-publisher / wx-mp-engagement / xhs-publish / xhs-content-ops / xianyu-ops / zhihu-publish / wxwork-moments 等)属平台运营范畴,按出发点 1.1 仍归 main agent,不在本次重构内。 +main agent 其余发布/运营技能(douyin-video-publish / wechat-channels-publish / weibo-publish / wx-mp-publisher / wx-mp-engagement / xhs-publish / xhs-content-ops / xianyu-ops / zhihu-publish / wxwork-moments 等)属平台运营范畴,按出发点 1.1 仍归 main agent,不在本次重构内。 ### 2.2 content-producer 现有视频相关技能清单 diff --git a/package.json b/package.json index 54ff191c..ee567e9f 100644 --- a/package.json +++ b/package.json @@ -1,3 +1,6 @@ { - "packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f" + "packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f", + "dependencies": { + "ioredis": "^6.0.0" + } } diff --git a/patches/camoufox-cli/README.md b/patches/camoufox-cli/README.md index abbe0e92..c669a975 100644 --- a/patches/camoufox-cli/README.md +++ b/patches/camoufox-cli/README.md @@ -19,7 +19,7 @@ camoufox-cli upload @ref|selector [more files...] - Variadic — one or more file paths. (Upstream has no upload at all.) - Fails fast with `File not found:

` before touching the browser if any path is missing. -Used by the publish skills (`douyin-publish` / `xhs-publish` / `weibo-publish` / `zhihu-publish` / `wechat-channels-publish` / `youtube-publish`). +Used by the publish skills (`douyin-video-publish` / `xhs-publish` / `weibo-publish` / `zhihu-publish` / `wechat-channels-publish` / `youtube-publish`). ### 2. Fail-first queue (daemon-side) @@ -82,8 +82,8 @@ cd patches/camoufox-cli && npm test ``` Upstream tests are vendored unchanged. New tests: -- `tests/cli.test.ts` — `upload` / `identity` arg parsing. -- `tests/server-queue.test.ts` — fail-first queue + `close` bypass (mocks `execute`). +- `../../test/camoufox-cli/cli.test.ts` — `upload` / `identity` arg parsing. +- `../../test/camoufox-cli/server-queue.test.ts` — fail-first queue + `close` bypass (mocks `execute`). ## Attribution diff --git a/patches/camoufox-cli/vitest.config.ts b/patches/camoufox-cli/vitest.config.ts index 19384e80..88798c7c 100644 --- a/patches/camoufox-cli/vitest.config.ts +++ b/patches/camoufox-cli/vitest.config.ts @@ -1,7 +1,2 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - }, -}); +// Preserve npm test / test:watch while keeping the suite in the repository test/. +export { default } from "../../test/vitest.config.mts"; diff --git a/skills/README.md b/skills/README.md index ff483c83..eee5783c 100644 --- a/skills/README.md +++ b/skills/README.md @@ -18,5 +18,5 @@ | `pexels-footage` | Pexels 免费素材搜索下载 | main + content-producer 继承 | | `pixabay-footage` | Pixabay 免费素材搜索下载 | main + content-producer 继承 | | `wxwork-drive` | 企业微信微盘 | main + content-producer 继承 | -| `siliconflow-img-gen` | 硅基流动生图(Phase 5 改火山) | main + content-producer 继承 | +| `awk-img-gen` | 阿里云百炼生图/编辑(业务空间 qwen-image / agent plan wan2.7-image) | main + content-producer 继承 | | `youtube-publish` | YouTube 视频发布(Data API v3 + OAuth2) | main + content-producer 继承 | diff --git a/skills/aigc-video-gen/SKILL.md b/skills/aigc-video-gen/SKILL.md index c70a9675..b8155d30 100644 --- a/skills/aigc-video-gen/SKILL.md +++ b/skills/aigc-video-gen/SKILL.md @@ -20,12 +20,12 @@ metadata: | 平台 | 环境变量 | 视频模型 | 音乐模型 | |------|---------|---------|---------| -| 阿里云百炼(优先) | `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`) | `happyhorse-1.1-i2v`、`happyhorse-1.1-t2v`、`happyhorse-1.1-r2v` | — | +| 阿里云百炼(优先) | 业务空间:`WORKSPACE_ID` + `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`);agent plan:`AWK_API_KEY` | `happyhorse-1.1-i2v`、`happyhorse-1.1-t2v`、`happyhorse-1.1-r2v` | — | | 火山引擎方舟 | `AWK_GEN_KEY` | `doubao-seedance-2-0-fast-260128`、`doubao-seedance-2-0-260128`、`doubao-seedance-2-0-mini-260615` | — | | MiniMax Hailuo | `MINIMAX_API_KEY` | `MiniMax-H3` | `music-3.0` | - 三个平台的上述视频模型**均支持声画同出**(t2v / i2v / r2v 三种模式)。 -- **平台自动判断写在 `aigc-video-gen.sh` 里**:argv 含 `--platform ` 时转发到对应供应商脚本(剔除 `--platform` 参数);无 `--platform` 时按 env 自动判——有 `MINIMAX_API_KEY` 走 MiniMax,否则有 `AWK_GEN_KEY` 走火山,否则有 `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY` 走百炼,三者皆无则输出提示让 Agent 改用 `pexels-footage` / `pixabay-footage`(退出码 2)。 +- **平台自动判断写在 `aigc-video-gen.sh` 里**:argv 含 `--platform ` 时转发到对应供应商脚本(剔除 `--platform` 参数);无 `--platform` 时按 env 自动判——有 `MINIMAX_API_KEY` 走 MiniMax,否则有 `AWK_GEN_KEY` 走火山,否则有 `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY`/`WORKSPACE_ID` 走百炼,否则有 `AWK_API_KEY` 走百炼(agent plan 兜底,排最后:它是主模型 key,只在无显式视频平台凭据时触发),皆无则输出提示让 Agent 改用 `pexels-footage` / `pixabay-footage`(退出码 2)。 - **三供应商脚本拆分**(共享逻辑在 `scripts/aigc_common.py`,与三脚本同目录): - `scripts/gen_minimax.py` — MiniMax Hailuo 视频生成(含 `--ref-audio` 多模态参考)+ `music` 子命令 - `scripts/gen_volc.py` — 火山引擎 Seedance 视频生成 @@ -59,11 +59,15 @@ Agent 读到此报错后的处理流程: - 候选链(每模式一条):`happyhorse-1.1-{mode}` → `happyhorse-1.0-{mode}` → `wan2.7-{mode}`。首选模型不可用或任务失败时 `gen.py` 自动沿链降级,无需人工干预。 - **`--model ` 可显式覆盖**(关闭候选链 fallback,只用该模型);非必要不覆盖。 -### WORKSPACE_ID 端点规则 +### 百炼端点双模式规则 -配了 `WORKSPACE_ID` 时,happyhorse 走专属端点 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1`(华北2,更快);没配则走默认 `https://dashscope.aliyuncs.com/api/v1`。 +| 模式 | 触发条件 | 端点 | +|------|---------|------| +| 业务空间(优先) | `WORKSPACE_ID` + `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY` | `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1` | +| agent plan | 无业务空间凭据时用 `AWK_API_KEY` | `https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1` | +| legacy 兼容 | 都没有但 `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY` 在 | `https://dashscope.aliyuncs.com/api/v1`(老部署) | -这个设置对于火山(doubao-seedance 系列模型)和 MiniMax 无效。 +> `WORKSPACE_ID` 配了但业务空间 key 缺失时打 warning 落 agent plan。端点模式对火山(doubao-seedance 系列)和 MiniMax 无效。 ### 火山候选链 @@ -110,6 +114,15 @@ Agent 读到此报错后的处理流程: - 每个片段时长 **不得超过 15 秒** - 超过上限的内容**必须在脚本中拆成多个片段** +### 转场片段规范(i2v 首尾帧插值) + +用 i2v 生成两段素材之间的转场片段(如反转植入的「画面转场式」)时: + +- **首帧 / 尾帧图**分别取自前后两段素材的关键帧;**选帧即锁定景别**——首帧景别偏松会导致整段转场弃用重生成,用与前后段匹配的景别帧做首帧。 +- prompt 必须含**风格对齐要求**:对齐相邻素材的材质 / 光影 / 色调。AIGC 画风漂移是通病,靠「母题延续」弱化——用前后段共享的核心意象(如瞳孔→光晕→数据面板)做过渡主体,prompt 里写明。 +- 转场片段**帧率由生成端决定,无 CLI 参数指定**(实测 24fps);与主素材帧率不一致时由调用方拼接工具统一,规划期预判最低公共规格。 +- 生成后先**抽帧自检**(确认首尾之间是真实渐变而非硬切、画风可接受),再进拼接;首版不满意时重生成优于硬修。 + ## Run 通过 PATH 调用 wrapper,无需拼接脚本路径。 @@ -188,11 +201,12 @@ aigc-video-gen music \ | Variable | Description | |----------|-------------| -| `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | 阿里云百炼 API key(优先平台) | +| `WORKSPACE_ID` + `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | 百炼业务空间(优先模式) | +| `AWK_API_KEY` | 百炼 agent plan key(token-plan 端点;无业务空间凭据时启用) | | `AWK_GEN_KEY` | 火山方舟视频生成专用 key(不可与 `ARK_API_KEY` 混用) | | `MINIMAX_API_KEY` | MiniMax API key(Hailuo-H3 视频生成 + 背景音乐生成共用) | | `WORKSPACE_ID` | 可选,百炼专属端点加速 | ## Fallback 路径 -`MODELSTUDIO_API_KEY`、`AWK_GEN_KEY`、`MINIMAX_API_KEY` 均未配 → `gen.py` 退出码 2,Agent 应改用 `pexels-footage` / `pixabay-footage` 走 Stock Footage 模式兜底。 +`MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY`/`WORKSPACE_ID`/`AWK_API_KEY`、`AWK_GEN_KEY`、`MINIMAX_API_KEY` 均未配 → `gen.py` 退出码 2,Agent 应改用 `pexels-footage` / `pixabay-footage` 走 Stock Footage 模式兜底。 diff --git a/skills/aigc-video-gen/aigc-video-gen.sh b/skills/aigc-video-gen/aigc-video-gen.sh index 89e2a82a..4e08df2d 100755 --- a/skills/aigc-video-gen/aigc-video-gen.sh +++ b/skills/aigc-video-gen/aigc-video-gen.sh @@ -10,8 +10,10 @@ # Dispatch 顺序: # 1. argv 含 --platform → 转发到对应 gen_*.py(剔除 --platform 参数) # 2. 否则按 env 自动判:MINIMAX_API_KEY → minimax;AWK_GEN_KEY → volcengine; -# MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY → dashscope -# 3. 三者皆无 → 输出提示让 Agent 改用 pexels-footage / pixabay-footage(退出码 2) +# MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY/WORKSPACE_ID → dashscope; +# AWK_API_KEY → dashscope(agent plan 模式,排最后:它是主模型 key, +# 只在没有任何显式视频平台凭据时才兜底触发百炼) +# 3. 皆无 → 输出提示让 Agent 改用 pexels-footage / pixabay-footage(退出码 2) set -euo pipefail SELF="${BASH_SOURCE[0]}" # Resolve symlink (wrapper is ln -sfn'd into ~/.openclaw/bin) so SCRIPT_DIR points at the real skill dir. @@ -55,11 +57,13 @@ if [ -z "$PLATFORM" ]; then PLATFORM="minimax" elif [ -n "${AWK_GEN_KEY:-}" ]; then PLATFORM="volcengine" - elif [ -n "${MODELSTUDIO_API_KEY:-}" ] || [ -n "${DASHSCOPE_API_KEY:-}" ]; then + elif [ -n "${MODELSTUDIO_API_KEY:-}" ] || [ -n "${DASHSCOPE_API_KEY:-}" ] || [ -n "${WORKSPACE_ID:-}" ]; then + PLATFORM="dashscope" + elif [ -n "${AWK_API_KEY:-}" ]; then PLATFORM="dashscope" else echo "[error] 未检测到任何视频生成平台的环境变量" >&2 - echo " (MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY / AWK_GEN_KEY / MINIMAX_API_KEY 均未设置)。" >&2 + echo " (MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY / WORKSPACE_ID / AWK_API_KEY / AWK_GEN_KEY / MINIMAX_API_KEY 均未设置)。" >&2 echo "[hint] 请改用 pexels-footage 和 pixabay-footage 技能搜集素材:" >&2 echo " 1) pexels-footage 搜索并下载 9:16 竖屏素材" >&2 echo " 2) pexels 无结果时用 pixabay-footage 兜底" >&2 diff --git a/skills/aigc-video-gen/scripts/gen_dashscope.py b/skills/aigc-video-gen/scripts/gen_dashscope.py index 36544343..ecff560a 100644 --- a/skills/aigc-video-gen/scripts/gen_dashscope.py +++ b/skills/aigc-video-gen/scripts/gen_dashscope.py @@ -9,12 +9,11 @@ 模型候选链(每模式一条): happyhorse-1.1-{mode} → happyhorse-1.0-{mode} → wan2.7-{mode} -鉴权:HTTP header `Authorization: Bearer ${MODELSTUDIO_API_KEY}`(或 DASHSCOPE_API_KEY)。 - -端点规则: - - 配了 WORKSPACE_ID 时,happyhorse 走专属端点 {WorkspaceId}.cn-beijing.maas.aliyuncs.com(更快) - - 没配则走默认 dashscope.aliyuncs.com - - wan2.7 始终走默认端点 +端点/key 双模式(2026-09 provider 收敛,ds_resolve): + - 业务空间(优先):WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY + → https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1 + - agent plan:否则 AWK_API_KEY → https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1 + - legacy 兼容:都没有但 MODELSTUDIO/DASHSCOPE 在 → 默认 dashscope.aliyuncs.com """ from __future__ import annotations @@ -44,8 +43,9 @@ # ---- 百炼端点与常量 ----------------------------------------------------------- -DS_DEFAULT_BASE = "https://dashscope.aliyuncs.com/api/v1" +DS_DEFAULT_BASE = "https://dashscope.aliyuncs.com/api/v1" # legacy 兼容端点 DS_WS_BASE_TEMPLATE = "https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1" +DS_AGENT_PLAN_BASE = "https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1" DS_CREATE_PATH = "/services/aigc/video-generation/video-synthesis" DS_QUERY_PATH = "/tasks/{task_id}" @@ -62,17 +62,30 @@ DS_TIMEOUT = 900 -def ds_base_for_model(model: str) -> str: - """Resolve the DashScope base URL for a given model. +def ds_resolve() -> tuple[str, str, str]: + """解析百炼端点模式。返回 (base, api_key, mode)。 - happyhorse-1.1 / 1.0 在默认 dashscope.aliyuncs.com 端点可正常调用(WorkspaceId 端点 - 只是华北2的性能优化,非必需)。WORKSPACE_ID 设置时走专属端点更快,否则走默认。 - wan2.7 始终走默认端点。 + - 业务空间(优先):WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY + - agent plan:否则 AWK_API_KEY(token-plan 端点) + - legacy 兼容:都没有但 MODELSTUDIO/DASHSCOPE 在 → 默认端点(老部署无 AWK_API_KEY 时) + - 都不可用 → die """ wsid = (os.environ.get("WORKSPACE_ID") or "").strip() - if model.startswith("happyhorse") and wsid: - return DS_WS_BASE_TEMPLATE.format(wsid=wsid) - return DS_DEFAULT_BASE + ws_key = ( + os.environ.get("MODELSTUDIO_API_KEY") + or os.environ.get("DASHSCOPE_API_KEY") + or "" + ).strip() + if wsid and ws_key: + return DS_WS_BASE_TEMPLATE.format(wsid=wsid), ws_key, "workspace" + if wsid: + log("WORKSPACE_ID 已配置但 MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY 缺失,尝试 agent plan") + awk_key = (os.environ.get("AWK_API_KEY") or "").strip() + if awk_key: + return DS_AGENT_PLAN_BASE, awk_key, "agent-plan" + if ws_key: + return DS_DEFAULT_BASE, ws_key, "legacy" + die("百炼视频生成凭据未配置:需 WORKSPACE_ID+MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY(业务空间)或 AWK_API_KEY(agent plan)") # ---- 百炼视频生成 ------------------------------------------------------------- @@ -166,7 +179,7 @@ def ds_candidates(args: argparse.Namespace, mode: str) -> list[str]: def run_one(platform: str, model: str, args: argparse.Namespace, api_key: str) -> str: """Submit + poll for a single DashScope model. Returns video URL or raises.""" - base = ds_base_for_model(model) + base, _key, _mode = ds_resolve() task_id = ds_submit(model, args, api_key, base) log(f"dashscope task submitted: {task_id} (model={model} base={base})") return ds_poll(task_id, api_key, base) @@ -179,13 +192,7 @@ def cmd_video(args: argparse.Namespace) -> None: # --prev-segment: 抽取上一段末帧作为本段首帧(人物故事首尾帧对齐) resolve_prev_segment(args) - api_key = ( - os.environ.get("MODELSTUDIO_API_KEY") - or os.environ.get("DASHSCOPE_API_KEY") - or "" - ).strip() - if not api_key: - die("MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY 未设置") + base, api_key, provider_mode = ds_resolve() has_ref = bool(args.ref_image or args.ref_video) has_i2v = bool(args.image or args.last_frame) @@ -197,7 +204,7 @@ def cmd_video(args: argparse.Namespace) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) log( - f"platform=dashscope mode={mode} candidates={candidates} " + f"platform=dashscope provider={provider_mode} mode={mode} candidates={candidates} " f"duration={args.duration}s ratio={args.ratio} resolution={args.resolution}" ) video_url = generate("dashscope", candidates, args, api_key, run_one) @@ -208,6 +215,7 @@ def cmd_video(args: argparse.Namespace) -> None: json.dumps( { "platform": "dashscope", + "provider_mode": provider_mode, "mode": mode, "model_candidates": candidates, "duration": args.duration, diff --git a/skills/awk-img-gen/SKILL.md b/skills/awk-img-gen/SKILL.md new file mode 100644 index 00000000..9b88d213 --- /dev/null +++ b/skills/awk-img-gen/SKILL.md @@ -0,0 +1,127 @@ +--- +name: awk-img-gen +description: 阿里云百炼图像生成/编辑。业务空间(WORKSPACE_ID)走 qwen-image-3.0 系候选链,agent plan 走 wan2.7-image;文生图默认,1-3 张参考图触发编辑/多图融合。封面海报直接渲染文字,不要后期拼字。 +metadata: + openclaw: + emoji: 🖼️ + requires: + bins: + - python3 + homepage: https://docs.bailian.console.aliyun.com/zh/model-studio/qwen-image-generation-and-editing-api-reference +--- + +# 阿里云百炼图像生成(awk-img-gen) + +走百炼 DashScope 同步 `multimodal-generation` 接口生成/编辑图片,落盘 PNG + `prompts.json` 索引 + `index.html` 缩略图 gallery。 + +> **凭据**(双模式,自动判断,无需 `--platform` 类参数): +> +> | 模式 | 触发条件 | 端点 | 模型候选链 | +> |------|---------|------|-----------| +> | 业务空间(优先) | `WORKSPACE_ID` + `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`) | `https://{WORKSPACE_ID}.cn-beijing.maas.aliyuncs.com/api/v1` | `qwen-image-3.0-pro` → `qwen-image-3.0` → `qwen-image-2.0-pro-2026-06-22` | +> | agent plan | 无业务空间凭据时用 `AWK_API_KEY` | `https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1` | `wan2.7-image-pro` → `wan2.7-image` | +> +> 候选链自动 fallback(模型未开通/未找到/无权限时切下一个);`--model` 显式指定时关闭 fallback。 +> 两套凭据都缺 → exit 1 并打印配置指引;实拍图兜底改走 `pexels-footage` / `pixabay-footage`。 +> 应该 spawn IT engineer subagent 配置环境变量,**不要自己写环境变量文件**。 + +## Run + +Note: 图像生成可能耗时 10–120 秒(qwen-image-3.0-pro 更慢)。exec 调用时把 timeout 设高(如 `exec timeout=300`)。 + +**Do NOT set env vars inline**(例如 `AWK_API_KEY=... python3 ...`)。env var 已在系统环境里,inline 赋值会破坏 exec 权限检查。 + +通过 PATH 调用 wrapper,无需拼接脚本路径: + +```bash +# Text-to-image(模式/模型/候选链全自动,默认 2048x2048) +awk-img-gen --prompt "your prompt here" + +# 竖版 9:16(短视频封面) +awk-img-gen --prompt "..." --image-size 1536x2688 + +# 指定模型(显式指定时不 fallback) +awk-img-gen --prompt "..." --model "qwen-image-2.0-pro-2026-06-22" + +# Image-edit(1-3 张参考图;URL / data URI / 本地路径均可) +awk-img-gen --prompt "add a lighthouse" --image "https://example.com/source.jpg" +awk-img-gen --prompt "blend" \ + --image ./tmp/ref-a.png \ + --image2 ./tmp/ref-b.png \ + --image3 ./tmp/ref-c.png +``` + +## Parameters + +| Flag | Default | Description | +|------|---------|-------------| +| `--prompt` | required | 图像描述;要渲染的文字**直接写完整句子**(≤ 约1300 token,超长静默截断) | +| `--model` | auto | Model ID;缺省按模式走候选链自动 fallback,显式指定时不 fallback | +| `--image-size` | `2048x2048` | `WxH`(总像素 512²~2048²,宽高比 1:8~8:1)或 `auto`;编辑模式缺省跟随输入图 | +| `--seed` | — | 随机种子 [0, 2147483647],需要可复现时设固定值 | +| `--watermark` | `false` | 是否加水印(xiaobei 默认不加,避免后续 image 工具处理) | +| `--prompt-extend` | off | 允许百炼自动扩写 prompt(API 默认开;本脚本默认**关**,保证封面文字/布局指令精确;氛围图想要更丰富细节时可开) | +| `--image` | — | 参考图 1(启用编辑模式) | +| `--image2` / `--image3` | — | 参考图 2 / 3(多图融合) | +| `--out-dir` | `./tmp/awk-img-` | 输出目录 | + +### 推荐尺寸(qwen-image 文档推荐值) + +| Value | Ratio | +|-------|-------| +| `2048x2048` | 1:1(默认) | +| `2688x1536` | 16:9 | +| `1536x2688` | 9:16 | +| `2368x1728` | 4:3 | +| `1728x2368` | 3:4 | + +> 总像素须在 512×512 ~ 2048×2048 之间,宽高比 1:8 ~ 8:1;无效值脚本拒绝并 exit 1 列出推荐值。 + +## Output + +- `*.png` 图像(百炼输出 PNG;URL 24h 有效,脚本已下载到本地) +- `prompts.json` 索引 → prompt + model + provider_mode + URL + file +- `index.html` 缩略图 gallery + +## 视频封面/海报最佳实践 + +适用于**图文混合素材**(短视频封面、社媒海报、信息图配图等)——需要模型一次性渲染文字与画面,而不是后期合成。 + +### 1. 参数推荐 + +| 参数 | 推荐值 | 原因 | +|------|--------|------| +| `--model` | 缺省(走候选链主力) | qwen-image-3.0-pro / wan2.7-image-pro 文字渲染与中文支持最好 | +| `--image-size` | 按平台比例选推荐尺寸 | 9:16 用 `1536x2688`,16:9 用 `2688x1536` | +| `--prompt-extend` | 不加(保持默认关) | 扩写会改写你精心排版的文字与布局指令 | + +### 2. Prompt 写法(关键) + +**❌ 反例**(泛泛描述): +> "Generate an attractive short-video cover with a title about AI" + +**✅ 正例**(按视觉布局分段写,明确写出要渲染的文字): +> "A dramatic vertical 9:16 short-video cover. Background: bold red-to-black gradient. Top: glowing AI chip icons with text 'DeepSeek'. Middle: large bold Chinese text '前几周 DeepSeek 还是神一般的存在' in white and gold gradient with sharp shadows. Bottom: dramatic red glowing Chinese text '为什么热度消散得这么快?' with lightning effects. Style: high contrast, modern tech poster, dramatic lighting, professional Chinese typography, sharp text rendering, cinematic, no watermarks." + +要点: +- **要写的字直接写完整句子**,不要"加个标题"这种空指令 +- **按布局分段**描述(top/middle/bottom 或 左/中/右),让模型知道字放哪 +- **指定字体特性**:颜色、渐变、阴影、发光、风格 +- **明确要求**:"sharp text rendering"、"professional Chinese typography" +- 末尾加 "no watermarks" 排除水印(与脚本 `--watermark false` 双保险) + +### 3. 生成后必须验证 + +1. 用 `image` 工具分析图片,**逐项确认**: + - ✅ 文字内容是否完全正确(不能错字、漏字、出现乱码字符) + - ✅ 文字是否清晰可读(无模糊、无变形) + - ✅ 布局是否符合预期 +2. 文字渲染错误 → 调整 prompt 重新生成(可加 `--seed` 复现好的构图再微调文字),**不要**交付错字封面 + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `WORKSPACE_ID` | 百炼业务空间 ID;配置后优先走业务空间端点 | +| `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | 业务空间 API key(与 `WORKSPACE_ID` 配对) | +| `AWK_API_KEY` | 百炼 agent plan key(token-plan 端点;无业务空间凭据时使用) | diff --git a/skills/siliconflow-img-gen/siliconflow-img-gen.sh b/skills/awk-img-gen/awk-img-gen.sh similarity index 72% rename from skills/siliconflow-img-gen/siliconflow-img-gen.sh rename to skills/awk-img-gen/awk-img-gen.sh index 714acd3a..da55aa30 100755 --- a/skills/siliconflow-img-gen/siliconflow-img-gen.sh +++ b/skills/awk-img-gen/awk-img-gen.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# siliconflow-img-gen.sh — siliconflow-img-gen 顶层 wrapper(薄转发) -# 让 agent 用 `siliconflow-img-gen ` 走 PATH,零路径拼接。 +# awk-img-gen.sh — awk-img-gen 顶层 wrapper(薄转发) +# 让 agent 用 `awk-img-gen ` 走 PATH,零路径拼接。 # 内部转发到 scripts/gen.py;wrapper 自身只是 exec 转发,不改语义。 set -euo pipefail SELF="${BASH_SOURCE[0]}" diff --git a/skills/awk-img-gen/scripts/gen.py b/skills/awk-img-gen/scripts/gen.py new file mode 100755 index 00000000..e421cd35 --- /dev/null +++ b/skills/awk-img-gen/scripts/gen.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""阿里云百炼图像生成/编辑(awk-img-gen)— stdlib only. + +Provider 收敛(2026-09 拍板):SiliconFlow(skill 旧名残留)→ 火山 Seedream(Phase 5)→ 阿里云百炼(现在)。 + +双模式(resolve_mode): + - 业务空间:WORKSPACE_ID 配置时优先 → https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1 + key = MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY + 模型候选链:qwen-image-3.0-pro → qwen-image-3.0 → qwen-image-2.0-pro-2026-06-22 + - agent plan:否则 → https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1 + key = AWK_API_KEY + 模型候选链:wan2.7-image-pro → wan2.7-image + +同步接口:POST {base}/services/aigc/multimodal-generation/generation +请求体(DashScope messages 风格,单轮,input_audio 家族同款端点): + {model, input:{messages:[{role:"user", + content:[{image:}×1-3(编辑模式), {text:}]}]}, + parameters:{size?, seed?, watermark, prompt_extend}} +响应:output.choices[0].message.content[*].image 为图片 URL(24h 有效),下载落盘。 + +参考:docs.bailian.console.aliyun.com「千问-图像生成与编辑 3.0 / qwen-image 2.0」 +与 modelstudioai/cli packages/core/src/client/image-routes.ts(sync-multimodal 家族)。 +""" +import argparse +import base64 +import json +import mimetypes +import os +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +# ── 端点与模型 ──────────────────────────────────────────────────────────────── + +WS_BASE_TEMPLATE = "https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1" +AGENT_PLAN_BASE = "https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1" +GEN_PATH = "/services/aigc/multimodal-generation/generation" + +# 业务空间模型候选链(主力 → fallback,用户 --model 显式指定时关闭 fallback) +WS_MODEL_CHAIN = ["qwen-image-3.0-pro", "qwen-image-3.0", "qwen-image-2.0-pro-2026-06-22"] +# agent plan 模型候选链 +PLAN_MODEL_CHAIN = ["wan2.7-image-pro", "wan2.7-image"] + +# 触发候选链 fallback 的 HTTP 状态码(模型未开通 / 未找到 / 无权限) +MODEL_UNAVAILABLE_CODES = {403, 404} +# 400 需结合 body 判断(可能是模型不存在,也可能是参数错——参数错不 fallback) +MODEL_ERROR_BODY_HINTS = ("modelnotfound", "model not found", "not exist", "unsupported model", "access denied") + +REQUEST_TIMEOUT = 300 + +# ── size 校验(qwen-image 系文档:总像素 [512*512, 2048*2048],宽高比 [1/8, 8])── + +DEFAULT_SIZE = "2048*2048" # 1:1 +MIN_TOTAL_PIXELS = 512 * 512 # 262144 +MAX_TOTAL_PIXELS = 2048 * 2048 # 4194304 +MIN_ASPECT_RATIO = 1 / 8 +MAX_ASPECT_RATIO = 8 + +# 推荐尺寸(qwen-image 2.0/3.0 文档推荐值,DashScope 风格 * 分隔) +SIZE_PRESETS = { + "2048*2048": "1:1 (默认)", + "2688*1536": "16:9", + "1536*2688": "9:16", + "2368*1728": "4:3", + "1728*2368": "3:4", +} + +IMAGE_MIME_BY_EXT = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".bmp": "image/bmp", ".tiff": "image/tiff", ".webp": "image/webp", +} + + +# ── 模式解析 ───────────────────────────────────────────────────────────────── + +def resolve_mode() -> tuple[str, str, list[str], str]: + """解析百炼端点模式。返回 (base, api_key, model_chain, mode)。 + + 优先业务空间(WORKSPACE_ID + MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY); + WORKSPACE_ID 配了但 key 缺失时打 warning 落到 agent plan; + 否则 agent plan(AWK_API_KEY)。都不可用时报错退出。 + """ + wsid = (os.environ.get("WORKSPACE_ID") or "").strip() + if wsid: + key = ( + os.environ.get("MODELSTUDIO_API_KEY") + or os.environ.get("DASHSCOPE_API_KEY") + or "" + ).strip() + if key: + return WS_BASE_TEMPLATE.format(wsid=wsid), key, WS_MODEL_CHAIN, "workspace" + print("[warn] WORKSPACE_ID 已配置但 MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY 缺失,尝试 agent plan", file=sys.stderr) + key = (os.environ.get("AWK_API_KEY") or "").strip() + if key: + return AGENT_PLAN_BASE, key, PLAN_MODEL_CHAIN, "agent-plan" + print("[error] 百炼生图凭据未配置:", file=sys.stderr) + print(" - 业务空间:WORKSPACE_ID + MODELSTUDIO_API_KEY(或 DASHSCOPE_API_KEY)", file=sys.stderr) + print(" - agent plan:AWK_API_KEY(token-plan 端点)", file=sys.stderr) + sys.exit(1) + + +# ── size 处理 ──────────────────────────────────────────────────────────────── + +def _parse_size(size_str: str) -> tuple[int, int] | None: + """解析 WxH / W*H / W×H 字符串。失败返回 None。""" + m = re.match(r"^\s*(\d+)\s*[xX×*]\s*(\d+)\s*$", size_str) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + + +def normalize_size(size_str: str) -> str: + """校验并规范化 size 为 DashScope 风格 'W*H';'auto' 原样放行;无效报错退出。""" + if size_str.strip().lower() == "auto": + return "auto" + parsed = _parse_size(size_str) + if parsed is None: + _print_size_error(size_str, "格式必须是 'WxH'(或 W*H)或 'auto'") + sys.exit(1) + w, h = parsed + total = w * h + ratio = w / h if h != 0 else 0 + if total < MIN_TOTAL_PIXELS: + _print_size_error(size_str, f"总像素 {total} 低于百炼最小值 {MIN_TOTAL_PIXELS}(512x512)") + sys.exit(1) + if total > MAX_TOTAL_PIXELS: + _print_size_error(size_str, f"总像素 {total} 高于百炼最大值 {MAX_TOTAL_PIXELS}(2048x2048)") + sys.exit(1) + if ratio < MIN_ASPECT_RATIO or ratio > MAX_ASPECT_RATIO: + _print_size_error(size_str, f"宽高比 {ratio:.4f} 超出百炼范围 [1/8, 8]") + sys.exit(1) + return f"{w}*{h}" + + +def _print_size_error(size_str: str, reason: str) -> None: + print(f"[error] --image-size '{size_str}' 无效:{reason}", file=sys.stderr) + print("[info] 推荐尺寸(总像素 512x512 ~ 2048x2048,宽高比 1:8 ~ 8:1):", file=sys.stderr) + for s, r in SIZE_PRESETS.items(): + print(f" {s} ({r})", file=sys.stderr) + + +# ── 图像引用解析 ────────────────────────────────────────────────────────────── + +def resolve_image_ref(value: str) -> str: + """把 --image 入参解析为百炼可接受的引用:URL / data URI 原样,本地文件转 data URI。""" + if value.startswith(("http://", "https://", "data:")): + return value + path = Path(value) + if not path.is_file(): + print(f"[error] 参考图不存在: {value}", file=sys.stderr) + sys.exit(1) + mime = IMAGE_MIME_BY_EXT.get(path.suffix.lower()) or mimetypes.guess_type(str(path))[0] or "image/png" + b64 = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{b64}" + + +# ── Payload 构造 ───────────────────────────────────────────────────────────── + +def build_payload(args: argparse.Namespace, model: str) -> dict: + """构造百炼 multimodal-generation 请求体(model 由调用方传入,便于候选链 fallback)。""" + content: list[dict] = [] + + # 编辑模式:1-3 张参考图在前,text 在后(qwen-image 3.0/2.0 与 wan2.7-image 同构) + is_edit_mode = bool(args.image) + if is_edit_mode: + for ref in (args.image, args.image2, args.image3): + if ref: + content.append({"image": resolve_image_ref(ref)}) + + content.append({"text": args.prompt}) + + parameters: dict = { + "watermark": bool(args.watermark), + # 默认关 prompt 改写:封面/海报场景要精确渲染指定文字,扩写会破坏布局指令。 + # 需要模型自动扩写润色时显式 --prompt-extend。 + "prompt_extend": bool(args.prompt_extend), + } + if args.seed is not None: + parameters["seed"] = args.seed + if is_edit_mode: + # 编辑模式默认不指定 size(跟随输入图);显式传了才带上 + if args.image_size: + parameters["size"] = normalize_size(args.image_size) + else: + parameters["size"] = normalize_size(args.image_size or DEFAULT_SIZE) + + return { + "model": model, + "input": {"messages": [{"role": "user", "content": content}]}, + "parameters": parameters, + } + + +# ── API 调用 ──────────────────────────────────────────────────────────────── + +class ImgGenHTTPError(Exception): + """百炼端 HTTP 错误(携带状态码与响应体,供 main 做候选链 fallback 决策)。""" + + def __init__(self, code: int, body: str) -> None: + super().__init__(f"HTTP {code}: {body}") + self.code = code + self.body = body + + +def is_model_unavailable(exc: ImgGenHTTPError) -> bool: + """判断 HTTP 错误是否属于"模型层不可用"(可沿候选链 fallback)。 + + 403/404 直接算;400 需 body 命中模型类错误关键词(参数错的 400 不 fallback, + 换模型也一样错,快速失败暴露真实原因)。 + """ + if exc.code in MODEL_UNAVAILABLE_CODES: + return True + if exc.code == 400: + lowered = exc.body.lower() + return any(hint in lowered for hint in MODEL_ERROR_BODY_HINTS) + return False + + +def api_request(url: str, payload: dict, api_key: str) -> dict: + """调百炼 multimodal-generation;返回解析后的 JSON。失败抛 ImgGenHTTPError。""" + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode(errors="replace") + raise ImgGenHTTPError(e.code, body) + + +def extract_image_urls(resp: dict) -> list[str]: + """从响应提取图片 URL:output.choices[*].message.content[*].image。""" + urls: list[str] = [] + output = resp.get("output") or {} + for choice in output.get("choices") or []: + message = choice.get("message") or {} + content = message.get("content") + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("image"): + urls.append(item["image"]) + elif isinstance(content, str) and content.startswith("http"): + urls.append(content) + return urls + + +# ── 图像下载 ──────────────────────────────────────────────────────────────── + +def download_image(url: str, dest_path: Path) -> None: + """下载图片到本地。链接 24h 内有效(按百炼文档)。""" + req = urllib.request.Request(url, headers={"User-Agent": "wiseflow-awk-img-gen/3.0"}) + with urllib.request.urlopen(req, timeout=120) as resp: + dest_path.write_bytes(resp.read()) + + +def _print_enable_guide(mode: str, failed_model: str) -> None: + """候选链全部不可用时,输出开通指引(供 Agent 转告用户)。""" + print("", file=sys.stderr) + print(f"[error] 图像生成模型 {failed_model} 不可用(模式={mode}),候选链已全部尝试。", file=sys.stderr) + if mode == "workspace": + print("[guide] 请到阿里云百炼控制台检查业务空间模型授权:", file=sys.stderr) + print(" 1. 打开 https://bailian.console.aliyun.com/", file=sys.stderr) + print(f" 2. 确认业务空间(WORKSPACE_ID)已授权模型:{'、'.join(WS_MODEL_CHAIN)}", file=sys.stderr) + print(" 3. 确认 MODELSTUDIO_API_KEY 属于该业务空间", file=sys.stderr) + else: + print("[guide] 请检查 Agent Plan(token-plan)订阅:", file=sys.stderr) + print(f" 1. 确认订阅包含图像生成能力,模型:{'、'.join(PLAN_MODEL_CHAIN)}", file=sys.stderr) + print(" 2. 确认 AWK_API_KEY 为百炼 agent plan 的 key(sk-sp-* 形态)", file=sys.stderr) + print("[hint] 免 key 实拍图片可退公共技能 pexels-footage / pixabay-footage(非 AI 生成)。", file=sys.stderr) + + +# ── main ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="阿里云百炼图像生成/编辑(业务空间 qwen-image / agent plan wan2.7-image)" + ) + parser.add_argument("--prompt", required=True, help="图像描述(要渲染的文字直接写完整句子)") + parser.add_argument( + "--model", default=None, + help="Model ID(缺省按模式走候选链自动 fallback;显式指定时不 fallback)", + ) + parser.add_argument( + "--image-size", default=None, dest="image_size", + help="尺寸:'WxH'(如 2048x2048,总像素 512²~2048²)或 'auto';缺省 2048x2048", + ) + parser.add_argument("--seed", type=int, default=None, help="随机种子 [0, 2147483647]") + parser.add_argument( + "--watermark", choices=["true", "false"], default="false", + help="是否加水印(百炼默认 false;xiaobei 保持 false 避免后续 image 工具处理)", + ) + parser.add_argument( + "--prompt-extend", action="store_true", dest="prompt_extend", + help="允许百炼自动扩写 prompt(API 默认开;本脚本默认关,保证封面文字/布局指令精确)", + ) + # image-edit inputs(URL / data URI / 本地文件路径均可) + parser.add_argument("--image", default=None, help="参考图 1:URL 或本地路径(启用编辑模式)") + parser.add_argument("--image2", default=None, help="参考图 2(编辑模式,多图融合)") + parser.add_argument("--image3", default=None, help="参考图 3(编辑模式,多图融合)") + parser.add_argument("--out-dir", default=None, dest="out_dir", help="输出目录") + args = parser.parse_args() + + base, api_key, chain, mode = resolve_mode() + + # watermark 字段百炼期望 bool(JSON),从字符串转 + args.watermark = args.watermark == "true" + + ts = int(time.time()) + out_dir = Path(args.out_dir) if args.out_dir else Path(f"./tmp/awk-img-{ts}") + out_dir.mkdir(parents=True, exist_ok=True) + + # 候选模型:用户显式 --model 时不 fallback;否则按模式走候选链 + candidates = [args.model] if args.model else list(chain) + is_edit_mode = bool(args.image) + gen_mode = "image-edit" if is_edit_mode else "text-to-image" + + url = f"{base}{GEN_PATH}" + result: dict | None = None + for idx, cand_model in enumerate(candidates): + payload = build_payload(args, cand_model) + size = (payload.get("parameters") or {}).get("size", "-") + print(f"[info] Mode={gen_mode} provider={mode} model={cand_model} size={size}", file=sys.stderr) + try: + result = api_request(url, payload, api_key) + break + except ImgGenHTTPError as e: + print(f"[error] HTTP {e.code}: {e.body[:500]}", file=sys.stderr) + is_last = idx == len(candidates) - 1 + if is_model_unavailable(e) and not is_last: + print(f"[warn] model {cand_model} 不可用 (HTTP {e.code}),切换候选链下一个...", file=sys.stderr) + continue + if is_model_unavailable(e): + _print_enable_guide(mode, cand_model) + sys.exit(1) + + if result is None: + _print_enable_guide(mode, candidates[-1]) + sys.exit(1) + + # 百炼响应:output.choices[0].message.content[*].image → URL(24h 有效) + image_urls = extract_image_urls(result) + if not image_urls: + print(f"[error] 响应中无图片 URL: {json.dumps(result, ensure_ascii=False)[:800]}", file=sys.stderr) + sys.exit(1) + + prompts_map: dict = {} + for i, image_url in enumerate(image_urls): + dest = out_dir / f"{i:02d}.png" + print(f"[info] Downloading image {i} → {dest}", file=sys.stderr) + download_image(image_url, dest) + prompts_map[str(i)] = { + "prompt": args.prompt, + "model": result.get("model", candidates[0] if not args.model else args.model), + "provider_mode": mode, + "url": image_url, + "file": str(dest), + } + + (out_dir / "prompts.json").write_text(json.dumps(prompts_map, ensure_ascii=False, indent=2)) + + # 简单 HTML gallery + gallery_html = [""] + for i in range(len(image_urls)): + gallery_html.append(f'') + gallery_html.append("") + (out_dir / "index.html").write_text("\n".join(gallery_html)) + + usage = result.get("usage") or {} + print(f"[done] {len(image_urls)} image(s) saved to {out_dir}/ (usage: {json.dumps(usage, ensure_ascii=False)})", file=sys.stderr) + for k, v in prompts_map.items(): + print(f" [{k}] {v['file']}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/awk-tts/SKILL.md b/skills/awk-tts/SKILL.md index 92fcd65d..dfccffcd 100644 --- a/skills/awk-tts/SKILL.md +++ b/skills/awk-tts/SKILL.md @@ -1,6 +1,6 @@ --- name: awk-tts -description: 火山方舟豆包语音合成 2.0(seed-tts-2.0 字符版)文本转语音。生成 MP3/PCM/WAV/OGG_OPUS 旁白音频,凭据走 VOLC_TTS_*(旧控制台双头)或 VOLC_TTS_APP_KEY(新控制台单头),与 viral-chaser 火山 ASR 同控制台开通。 +description: 多供应商旁白 TTS——火山豆包语音合成 2.0(seed-tts-2.0)+ 阿里云百炼(qwen-audio-3.0-tts),凭据在哪家走哪家(火山 → 百炼业务空间 → 百炼 agent plan)。生成 MP3/PCM/WAV/OPUS 旁白,可选字级时间戳,合成后自动 ASR 自检。 metadata: openclaw: emoji: 🔊 @@ -8,44 +8,27 @@ metadata: bins: - python3 - ffprobe - env: - - VOLC_TTS_APP_ID - - VOLC_TTS_ACCESS_KEY primaryEnv: VOLC_TTS_ACCESS_KEY homepage: https://www.volcengine.com/docs/6561/1598757 --- -# 火山方舟豆包语音合成(awk-tts) +# 多供应商旁白 TTS(awk-tts) -走火山 openspeech v3 单向流式接口合成旁白音频。 +火山 openspeech v3 单向流式 + 百炼 SpeechSynthesizer 双后端,凭据自动路由。 -## 优先级约定(强制) +## 供应商路由(脚本内部,凭据在哪家走哪家) -**优先使用 OpenClaw 内置 TTS 工具**(`tts_generate` 或 agent 内置语音合成能力)。 +| 优先级 | 供应商 | 触发凭据 | 模型/资源 | +|--------|--------|---------|-----------| +| 1 | 火山豆包语音合成 2.0 | `VOLC_TTS_APP_ID`+`VOLC_TTS_ACCESS_KEY`(旧双头)或 `VOLC_TTS_APP_KEY`(新单头) | `seed-tts-2.0`(克隆音色 `S_xxx` 自动路由 `seed-icl-2.0`) | +| 2 | 百炼业务空间 | `WORKSPACE_ID` + `MODELSTUDIO_API_KEY`/`DASHSCOPE_API_KEY` | `qwen-audio-3.0-tts-plus` → `qwen-audio-3.0-tts-flash` 候选链 | +| 3 | 百炼 agent plan | `AWK_API_KEY`(token-plan 端点) | 仅 `qwen-audio-3.0-tts-plus` | -OpenClaw 内置 TTS 不可用时,回退到本 skill 的本地脚本(要求环境变量已经配置火山 TTS 凭据)。 - -> 即:本 skill 的 `scripts/tts.py` 是 **fallback 路径**,不应首选。调用方在 SKILL.md 里描述 TTS 环节时,须照此优先级写明,不许把本脚本写成第一选择。 - -## 首次使用:开通火山语音合成 - -火山豆包语音合成 2.0 与本仓 `viral-chaser`(火山 ASR)同控制台、同范式开通: - -1. 登火山引擎控制台 → 左侧「开通管理」→ **「语音模型」**选项卡 -2. 拉到最下找 **「Doubao-语音合成-2.0」** → 点击「立即使用」 -3. 在跳转页选 **「试用」**(一开始送 2 万字符,可先用,后续再点开通付费) -4. 在该页面拿三项凭据:**APP ID**(数字)、**Access Token**、**Secret Key** - -**给小贝的凭据**(按控制台版本二选一): - -| 控制台版本 | 给小贝的变量 | 备注 | -|---------|------------|------| -| 旧控制台双头 | `VOLC_TTS_APP_ID`(数字 APP ID)+ `VOLC_TTS_ACCESS_KEY`(Access Token) | 与 viral-chaser ASR 同对范式,**Secret Key 不给** | -| 新控制台单头 | `VOLC_TTS_APP_KEY`(APP Key) | 旧控制台账号不要把 Secret Key 填到这里 | - -> 鉴权二选一(脚本优先旧控制台双头)。脚本自动判:同时给出 `VOLC_TTS_APP_ID`+`VOLC_TTS_ACCESS_KEY` 走旧双头;否则用 `VOLC_TTS_APP_KEY` 走新单头。 - -> 应该 spawn IT engineer subagent 写入实例环境变量,**不要自己写环境变量文件**,it-engineer具有相关背景知识。 +- 百炼候选链自动 fallback(模型未开通/未找到切下一个);`--model` 显式指定关闭 fallback(仅百炼生效)。 +- 百炼默认音色 `longanhuan_v3.6`;传了火山系音色 ID 会警告并换默认音色。 +- 格式映射:火山 `ogg_opus` 在百炼自动转 `opus`;百炼不支持 32000 采样率。 +- 语速 `--speech-rate [-50,100]` 在百炼线性映射为 rate [0.5,2.0];响度 `--loudness` 映射为 volume [0,100]。 +- 三组凭据都缺 → exit 1 并列出配置方式。应该 spawn IT engineer subagent 写入实例环境变量,**不要自己写环境变量文件**。 ## Run @@ -69,6 +52,9 @@ awk-tts \ --format wav \ --sample-rate 44100 \ --output ./assets/audio/demo.wav + +# 字级时间戳(旁白对齐用):火山走流式原生返回,百炼走 SSE + word_timestamp_enabled +awk-tts --text "..." --enable-subtitle --output ./assets/audio/narration.mp3 ``` ## Parameters @@ -78,22 +64,26 @@ awk-tts \ | `fragment_dir` | — | Optional fragment directory under `fragments/`; when set, reads `tts_requirement.md` and defaults output to `artifacts/speech.` | | `--text` | — | Text to synthesize. Required unless `--text-file` or `fragment_dir` is set | | `--text-file` | — | UTF-8 text file to synthesize. Must be relative and under `scripts`, `assets`, `tmp`, `output_videos`, `fragments`, or `/outputs/` | -| `--voice` | `zh_female_shuangkuaisisi_uranus_bigtts` | Speaker ID(火山音色,见下表) | -| `--format` | `mp3` | Audio format: `mp3`, `pcm`, `ogg_opus`, `wav` | -| `--sample-rate` | — | Optional sample rate: 8000/16000/22050/24000/32000/44100/48000 | -| `--speech-rate` | — | Optional speech rate, range `-50`–`100`(0=默认, 100=2x, -50=0.5x) | -| `--loudness` | — | Optional loudness, range `-50`–`100`(0=默认) | -| `--context-text` | — | Optional 情感控制上下文(如 '用撒娇甜蜜的语气',仅 2.0/克隆音色支持) | +| `--voice` | 火山 `zh_female_shuangkuaisisi_uranus_bigtts` / 百炼 `longanhuan_v3.6` | 音色 ID(按路由到的供应商选默认) | +| `--model` | — | 百炼模型 ID(显式指定关闭候选链 fallback;火山模式忽略) | +| `--format` | `mp3` | Audio format: `mp3`, `pcm`, `wav`, `opus`(火山另收 `ogg_opus`,百炼自动映射为 `opus`) | +| `--sample-rate` | 火山不设 / 百炼 24000 | 8000/16000/22050/24000/44100/48000(火山另收 32000) | +| `--speech-rate` | — | Speech rate `-50`–`100`(0=默认, 100=2x, -50=0.5x;百炼线性映射 rate) | +| `--loudness` | — | Loudness `-50`–`100`(0=默认;百炼映射 volume) | +| `--context-text` | — | 情感/风格控制上下文(火山 `context_texts`;百炼 `instruction`) | | `--output` | — | Exact output file path under `assets/audio`, `tmp`, `output_videos`, `fragments`, or `/outputs/` | | `--out-dir` | `./tmp/awk-tts-` | Output directory under `assets/audio`, `tmp`, `output_videos`, `fragments`, or `/outputs/` when `--output` is not set | | `--overwrite` | off | Overwrite existing output audio/metadata files | | `--no-asr-check` | off | Skip ASR self-check after TTS generation | +| `--enable-subtitle` | off | 产出 `

+''' +try: + b.command('open', 'data:text/html;charset=utf-8,' + quote(html)) + title = '中文"反斜杠\\与emoji😀' + note.fill(b, title, '第一行\n第二行 #话题', 'none') + observed = b.eval('document.querySelector("input").dataset.state') + assert observed == title, repr(observed) + note.fill_input(b, 'input[placeholder*="搜索作品"]', title) + b.command('press', 'Enter') + assert b.eval('document.body.dataset.searched') == title + assert b.eval('JSON.parse(document.body.dataset.events)') == ['input', 'change', 'input', 'change'] + note.fill_input(b, 'input[placeholder*="搜索作品"]', '') + assert b.eval('document.querySelectorAll("input")[1].dataset.state') == '' + for setup in [ + 'document.querySelector("input").disabled=true', + 'document.querySelector("input").disabled=false; document.querySelector("input").readOnly=true', + 'document.querySelector("input").readOnly=false; document.body.append(document.querySelector("input").cloneNode())', + ]: + b.eval(setup) + try: + note.fill_input(b, 'input[placeholder="添加作品标题"]', '不应写入') + except RuntimeError: + pass + else: + raise AssertionError('non-editable or ambiguous input accepted') + print('PASS: real camoufox CLI: title/caption, controlled input events, search Enter, escaping, clear, disabled/read-only/ambiguous guards') +finally: + b.close() diff --git a/test/skills/test-upstream-catchup.ts b/test/skills/test-upstream-catchup.ts new file mode 100644 index 00000000..46da5a5c --- /dev/null +++ b/test/skills/test-upstream-catchup.ts @@ -0,0 +1,74 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { parseXhsNoteFromHtml, fetchXhsNoteFromHtml, XhsSecurityBlockError } from '../../crews/main/skills/_shared/xhs-html-note.ts' +import { collectComments } from '../../crews/main/skills/expert-douyin/tools/douyin-comments/scripts/fetch_comments.ts' + +function html(stream: unknown, extra='') { + return `${extra}` +} + +test('XHS EF/unknown buckets, numeric strings and camel/snake keys', () => { + const note=parseXhsNoteFromHtml(html({h264:[], EF4:[null,{master_url:'https://cdn/720',height:'720'}], + EF7:[{masterUrl:'https://cdn/1080-low',height:1080,avgBitrate:'100'}, + {master_url:'https://cdn/1080-high',height:'1080',avg_bitrate:'200'}], + unknown:[{masterUrl:'javascript:bad',height:4000}], metadata:{height:9000}}),'abc')! + assert.equal(note.videoUrl,'https://cdn/1080-high') + assert.equal(note.durationMs,3000) +}) +test('XHS h265 survives empty h264; malformed buckets use og video', () => { + assert.equal(parseXhsNoteFromHtml(html({h264:[],h265:[{masterUrl:'//cdn/video'}]}),'abc')?.videoUrl,'https://cdn/video') + assert.equal(parseXhsNoteFromHtml(html({EF5:null},''),'abc')?.videoUrl,'https://cdn/og') +}) +test('XHS content containing soft-block words is not a blocked page', () => { + const body=html({EF4:[{masterUrl:'https://cdn/ok'}]}).replace('Test','安全限制') + assert.equal(parseXhsNoteFromHtml(body,'abc')?.title,'安全限制') +}) +test('OpenCLI soft-block cooldown stays bounded to one retry', async () => { + const originalFetch=globalThis.fetch + const originalTimeout=globalThis.setTimeout + let calls=0 + globalThis.fetch=async () => {calls++; return new Response('安全限制',{status:200})} + globalThis.setTimeout=((callback: (...args: unknown[])=>void) => { + queueMicrotask(callback) + return 0 + }) as unknown as typeof setTimeout + try { + await assert.rejects(fetchXhsNoteFromHtml('abc',{xsecToken:'token'}),XhsSecurityBlockError) + assert.equal(calls,2) + } finally {globalThis.fetch=originalFetch; globalThis.setTimeout=originalTimeout} +}) +const item=(cid:string)=>({cid,text:cid}) +const page=(comments:unknown[],cursor=0,has_more=0,total=0)=>({ok:true,status:200,data:{status_code:0,comments,cursor,has_more,total}}) as any + +test('Douyin HTTP 200 empty body and status 8 are not expired login and do not retry',async()=>{ + for (const response of [{ok:true,status:200,data:null}, {ok:true,status:200,data:{status_code:8}}]) { + let calls=0 + const result=await collectComments('123',40,async()=>{calls++;return response}) + assert.equal(result.ok,false) + assert.match(result.error!,/^COMMENT_API_UNAVAILABLE/) + assert.equal(calls,1) + } +}) +test('Douyin deduplicates, retains partial comments and stops stalled cursor',async()=>{ + let calls=0; const waits:number[]=[] + const result=await collectComments('123',40,async()=>++calls===1 + ?page([item('a')],20,1,100):page([item('a')],20,1,100),async ms=>{waits.push(ms)}) + assert.equal(result.ok,false) + assert.equal(result.fetched,1) + assert.match(result.error!,/PAGINATION_STALLED/) + assert.equal(calls,2) + assert.equal(waits.length,1) + assert.ok(waits[0]>=1000 && waits[0]<3000) +}) +test('Douyin true empty is valid, positive-total empty is not',async()=>{ + assert.equal((await collectComments('123',40,async()=>page([]))).ok,true) + assert.equal((await collectComments('123',40,async()=>page([],0,0,10))).ok,false) + assert.equal((await collectComments('123',40,async()=>({ok:true,status:200,data:{status_code:0,comments:[]}}))).ok,false) +}) +test('Douyin keeps final page, marks limit truncation and preserves request failure',async()=>{ + assert.equal((await collectComments('123',40,async()=>page([item('a')],0,0,1))).fetched,1) + assert.equal((await collectComments('123',1,async()=>page([item('a')],20,1,10))).truncated,true) + assert.match((await collectComments('123',40,async()=>{throw new Error('network')})).error!,/COMMENT_REQUEST_FAILED: network/) +}) diff --git a/test/skills/test_img_gen.py b/test/skills/test_img_gen.py new file mode 100644 index 00000000..43488548 --- /dev/null +++ b/test/skills/test_img_gen.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Unit tests for gen.py (阿里云百炼图像生成/编辑,双模式). + +Covers: +- 模式解析:WORKSPACE_ID+MODELSTUDIO_API_KEY → 业务空间;否则 AWK_API_KEY → agent plan +- 端点常量:业务空间 maas.aliyuncs.com / agent plan token-plan +- 模型候选链:qwen-image-3.0-pro 链 / wan2.7-image-pro 链 +- size 校验(总像素 [512², 2048²],宽高比 [1/8, 8],规范化为 W*H,auto 放行) +- Payload 构造(text-to-image vs image-edit,content 顺序 image 在前 text 在后) +- 本地图转 data URI +- API request shape(Bearer token、multimodal-generation 端点) +- 响应解析 output.choices[*].message.content[*].image +- 候选链 fallback 判定(403/404 直接 fallback;400 仅模型类错误 fallback) + +All HTTP calls are mocked — these are unit tests. +""" +import base64 +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] / 'skills/awk-img-gen/scripts' +sys.path.insert(0, str(SCRIPTS_DIR)) + +import gen # noqa: E402 + + +def _env(**overrides): + """构造受控 env dict(清空所有相关变量后按需注入)。""" + base = { + k: v for k, v in os.environ.items() + if k not in ("WORKSPACE_ID", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "AWK_API_KEY") + } + base.update({k: v for k, v in overrides.items() if v is not None}) + return base + + +class TestConstants(unittest.TestCase): + def test_agent_plan_base(self): + self.assertEqual(gen.AGENT_PLAN_BASE, "https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1") + + def test_ws_base_template(self): + self.assertEqual( + gen.WS_BASE_TEMPLATE.format(wsid="llm-abc"), + "https://llm-abc.cn-beijing.maas.aliyuncs.com/api/v1", + ) + + def test_gen_path(self): + self.assertEqual(gen.GEN_PATH, "/services/aigc/multimodal-generation/generation") + + def test_model_chains(self): + self.assertEqual(gen.WS_MODEL_CHAIN[0], "qwen-image-3.0-pro") + self.assertIn("qwen-image-3.0", gen.WS_MODEL_CHAIN) + self.assertIn("qwen-image-2.0-pro-2026-06-22", gen.WS_MODEL_CHAIN) + self.assertEqual(gen.PLAN_MODEL_CHAIN[0], "wan2.7-image-pro") + self.assertIn("wan2.7-image", gen.PLAN_MODEL_CHAIN) + + def test_size_constraints_match_bailian_docs(self): + self.assertEqual(gen.MIN_TOTAL_PIXELS, 512 * 512) + self.assertEqual(gen.MAX_TOTAL_PIXELS, 2048 * 2048) + self.assertEqual(gen.MIN_ASPECT_RATIO, 1 / 8) + self.assertEqual(gen.MAX_ASPECT_RATIO, 8) + + +class TestResolveMode(unittest.TestCase): + def test_workspace_mode_preferred(self): + with mock.patch.dict(os.environ, _env( + WORKSPACE_ID="llm-abc", MODELSTUDIO_API_KEY="sk-ws", AWK_API_KEY="sk-sp-x" + ), clear=True): + base, key, chain, mode = gen.resolve_mode() + self.assertEqual(mode, "workspace") + self.assertEqual(base, "https://llm-abc.cn-beijing.maas.aliyuncs.com/api/v1") + self.assertEqual(key, "sk-ws") + self.assertEqual(chain, gen.WS_MODEL_CHAIN) + + def test_workspace_dashscope_key_alias(self): + with mock.patch.dict(os.environ, _env( + WORKSPACE_ID="llm-abc", DASHSCOPE_API_KEY="sk-ds" + ), clear=True): + base, key, chain, mode = gen.resolve_mode() + self.assertEqual(mode, "workspace") + self.assertEqual(key, "sk-ds") + + def test_agent_plan_when_no_workspace(self): + with mock.patch.dict(os.environ, _env(AWK_API_KEY="sk-sp-x"), clear=True): + base, key, chain, mode = gen.resolve_mode() + self.assertEqual(mode, "agent-plan") + self.assertEqual(base, gen.AGENT_PLAN_BASE) + self.assertEqual(chain, gen.PLAN_MODEL_CHAIN) + + def test_workspace_id_without_key_falls_to_agent_plan(self): + with mock.patch.dict(os.environ, _env( + WORKSPACE_ID="llm-abc", AWK_API_KEY="sk-sp-x" + ), clear=True): + _base, _key, _chain, mode = gen.resolve_mode() + self.assertEqual(mode, "agent-plan") + + def test_no_credentials_exits_1(self): + with mock.patch.dict(os.environ, _env(), clear=True): + with self.assertRaises(SystemExit) as ctx: + gen.resolve_mode() + self.assertEqual(ctx.exception.code, 1) + + +class TestSizeNormalization(unittest.TestCase): + def test_presets_accepted_and_normalized(self): + for size in ("2048x2048", "2688x1536", "1536x2688", "2368x1728", "1728x2368"): + self.assertEqual(gen.normalize_size(size), size.replace("x", "*")) + + def test_star_separator_accepted(self): + self.assertEqual(gen.normalize_size("2048*2048"), "2048*2048") + + def test_chinese_x_accepted(self): + self.assertEqual(gen.normalize_size("2048×2048"), "2048*2048") + + def test_auto_passthrough(self): + self.assertEqual(gen.normalize_size("auto"), "auto") + self.assertEqual(gen.normalize_size("AUTO"), "auto") + + def test_total_pixels_below_min_rejected(self): + # 500x500 = 250000 < 262144 + with self.assertRaises(SystemExit): + gen.normalize_size("500x500") + + def test_total_pixels_above_max_rejected(self): + # 2848x1600 = 4556800 > 4194304(旧火山 2K 预设,百炼超限) + with self.assertRaises(SystemExit): + gen.normalize_size("2848x1600") + + def test_aspect_ratio_out_of_range_rejected(self): + # 4096x400: ratio 10.24 > 8(面积 1638400 合法但比例超限) + with self.assertRaises(SystemExit): + gen.normalize_size("4096x400") + + def test_invalid_format_rejected(self): + for bad in ("abc", "1024", "1024x", "x1024", "", "2K", "4K"): + with self.assertRaises(SystemExit): + gen.normalize_size(bad) + + +class TestResolveImageRef(unittest.TestCase): + def test_url_passthrough(self): + self.assertEqual(gen.resolve_image_ref("https://a.com/x.jpg"), "https://a.com/x.jpg") + + def test_data_uri_passthrough(self): + ref = "data:image/png;base64,AAAA" + self.assertEqual(gen.resolve_image_ref(ref), ref) + + def test_local_file_to_data_uri(self): + import tempfile + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(b"\x89PNG\r\n\x1a\nfakebytes") + tmp = f.name + try: + ref = gen.resolve_image_ref(tmp) + self.assertTrue(ref.startswith("data:image/png;base64,")) + self.assertEqual(base64.b64decode(ref.split(",", 1)[1]), b"\x89PNG\r\n\x1a\nfakebytes") + finally: + os.unlink(tmp) + + def test_missing_file_exits_1(self): + with self.assertRaises(SystemExit): + gen.resolve_image_ref("/nonexistent/x.jpg") + + +class TestPayloadConstruction(unittest.TestCase): + def _args(self, **overrides): + defaults = dict( + prompt="a cat", model=None, image=None, image2=None, image3=None, + image_size=None, seed=None, watermark=False, prompt_extend=False, + ) + defaults.update(overrides) + return mock.Mock(**defaults) + + def test_text_to_image_default(self): + payload = gen.build_payload(self._args(), "qwen-image-3.0-pro") + self.assertEqual(payload["model"], "qwen-image-3.0-pro") + msgs = payload["input"]["messages"] + self.assertEqual(len(msgs), 1) + self.assertEqual(msgs[0]["role"], "user") + # 文生图 content 只有一个 text 项 + self.assertEqual(msgs[0]["content"], [{"text": "a cat"}]) + self.assertEqual(payload["parameters"]["size"], "2048*2048") + self.assertFalse(payload["parameters"]["watermark"]) + self.assertFalse(payload["parameters"]["prompt_extend"]) + + def test_explicit_size_normalized(self): + payload = gen.build_payload(self._args(image_size="1536x2688"), "m") + self.assertEqual(payload["parameters"]["size"], "1536*2688") + + def test_image_edit_content_order(self): + # 编辑模式:image 项在前,text 在后(百炼文档要求) + payload = gen.build_payload( + self._args(image="https://a.com/x.jpg", image2="https://a.com/y.jpg"), "m" + ) + content = payload["input"]["messages"][0]["content"] + self.assertEqual(content[0], {"image": "https://a.com/x.jpg"}) + self.assertEqual(content[1], {"image": "https://a.com/y.jpg"}) + self.assertEqual(content[2], {"text": "a cat"}) + # 编辑模式缺省不带 size + self.assertNotIn("size", payload["parameters"]) + + def test_image_edit_explicit_size_included(self): + payload = gen.build_payload( + self._args(image="https://a.com/x.jpg", image_size="2048x2048"), "m" + ) + self.assertEqual(payload["parameters"]["size"], "2048*2048") + + def test_seed_and_flags(self): + payload = gen.build_payload(self._args(seed=42, watermark=True, prompt_extend=True), "m") + self.assertEqual(payload["parameters"]["seed"], 42) + self.assertTrue(payload["parameters"]["watermark"]) + self.assertTrue(payload["parameters"]["prompt_extend"]) + + +class TestApiRequest(unittest.TestCase): + @mock.patch("gen.urllib.request.urlopen") + def test_request_shape(self, mock_urlopen): + mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({ + "output": {"choices": [{"message": {"content": [{"image": "https://x/a.png"}]}}]}, + }).encode("utf-8") + url = f"{gen.AGENT_PLAN_BASE}{gen.GEN_PATH}" + resp = gen.api_request(url, {"model": "m"}, "sk-sp-test") + args, _ = mock_urlopen.call_args + req = args[0] + self.assertEqual(req.headers["Authorization"], "Bearer sk-sp-test") + self.assertEqual(req.headers["Content-type"], "application/json") + self.assertEqual(req.full_url, url) + self.assertEqual(gen.extract_image_urls(resp), ["https://x/a.png"]) + + @mock.patch("gen.urllib.request.urlopen") + def test_http_error_raises_with_body(self, mock_urlopen): + import urllib.error + from io import BytesIO + mock_urlopen.side_effect = urllib.error.HTTPError( + url="https://x/api", code=404, msg="Not Found", hdrs=None, fp=BytesIO(b'{"code":"ModelNotFound"}'), + ) + with self.assertRaises(gen.ImgGenHTTPError) as ctx: + gen.api_request("https://x/api", {}, "k") + self.assertEqual(ctx.exception.code, 404) + + +class TestExtractImageUrls(unittest.TestCase): + def test_multi_choices_multi_content(self): + resp = {"output": {"choices": [ + {"message": {"content": [{"image": "https://x/a.png"}, {"text": "ignored"}]}}, + {"message": {"content": [{"image": "https://x/b.png"}]}}, + ]}} + self.assertEqual(gen.extract_image_urls(resp), ["https://x/a.png", "https://x/b.png"]) + + def test_empty_output(self): + self.assertEqual(gen.extract_image_urls({}), []) + self.assertEqual(gen.extract_image_urls({"output": {}}), []) + + +class TestModelUnavailable(unittest.TestCase): + def test_403_404_always(self): + self.assertTrue(gen.is_model_unavailable(gen.ImgGenHTTPError(403, ""))) + self.assertTrue(gen.is_model_unavailable(gen.ImgGenHTTPError(404, ""))) + + def test_400_model_hints_only(self): + self.assertTrue(gen.is_model_unavailable(gen.ImgGenHTTPError(400, '{"code":"ModelNotFound"}'))) + self.assertTrue(gen.is_model_unavailable(gen.ImgGenHTTPError(400, "model not found: x"))) + self.assertFalse(gen.is_model_unavailable(gen.ImgGenHTTPError(400, '{"code":"InvalidParameter","message":"size invalid"}'))) + + def test_5xx_not_model_unavailable(self): + self.assertFalse(gen.is_model_unavailable(gen.ImgGenHTTPError(500, ""))) + self.assertFalse(gen.is_model_unavailable(gen.ImgGenHTTPError(429, ""))) + + +class TestIntegrationDryRun(unittest.TestCase): + """Subprocess-level smoke tests.""" + + def test_help_runs_without_env(self): + result = subprocess.run( + [sys.executable, str(SCRIPTS_DIR / "gen.py"), "--help"], + capture_output=True, text=True, timeout=10, check=False, + env=_env(), + ) + self.assertEqual(result.returncode, 0) + self.assertIn("image", result.stdout.lower()) + + def test_missing_credentials_exits_1(self): + result = subprocess.run( + [sys.executable, str(SCRIPTS_DIR / "gen.py"), "--prompt", "x"], + capture_output=True, text=True, timeout=10, check=False, + env=_env(), + ) + self.assertEqual(result.returncode, 1) + self.assertIn("AWK_API_KEY", result.stderr) + self.assertIn("WORKSPACE_ID", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/skills/test_note_link_dom.py b/test/skills/test_note_link_dom.py new file mode 100644 index 00000000..2712b16c --- /dev/null +++ b/test/skills/test_note_link_dom.py @@ -0,0 +1,92 @@ +"""Local DOM regression via camoufox-cli; never opens Douyin or publishes.""" +import sys +from pathlib import Path +import json +import shutil +import subprocess +import unittest +import uuid +from urllib.parse import quote + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / + 'crews/main/skills/expert-douyin/tools/douyin-note-publish/scripts')) +import publish_douyin_note as note + + +class LocalBrowser(note.Browser): + def __init__(self): + self.session = 'note-link-test-' + uuid.uuid4().hex[:10] + + def command(self, *args, timeout=60): + result = subprocess.run( + ['camoufox-cli', '--session', self.session, '--json', *args], + capture_output=True, text=True, timeout=timeout, check=True) + envelope = json.loads(result.stdout) + if envelope.get('success') is False or envelope.get('ok') is False: + raise RuntimeError(str(envelope)) + data = envelope.get('data') + return data.get('result') if isinstance(data, dict) and 'result' in data else data + + +@unittest.skipUnless(shutil.which('camoufox-cli'), 'camoufox-cli required') +class LinkDOMTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.b = LocalBrowser() + + @classmethod + def tearDownClass(cls): + cls.b.close() + + def card(self, text, style=''): + return f'''
+
{text}
+
编辑作品
+
''' + + def open(self, html): + self.b.command('open', 'data:text/html;charset=utf-8,' + quote(html)) + + def test_combined_title_caption_with_nested_markup(self): + self.open(self.card('发完笔记,前30分钟决定生死。这里是正文 #话题')) + result = note.note_link_candidate(self.b, '发完笔记,前30分钟决定生死') + self.assertEqual(result, {'titles':1,'actions':1,'status':'unique'}) + self.assertFalse(self.b.eval('!!document.body.dataset.clicked')) + note.note_link_candidate(self.b, '发完笔记,前30分钟决定生死', click=True) + self.assertEqual(self.b.eval('document.body.dataset.clicked'), '1') + + def test_hidden_duplicate_and_unrelated_card(self): + self.open(self.card('目标标题。正文') + self.card('目标标题。隐藏副本','display:none') + + self.card('另一篇作品正文包含目标标题')) + self.assertEqual(note.note_link_candidate(self.b,'目标标题')['status'],'unique') + + def test_multiple_prefix_candidates_do_not_click(self): + self.open(self.card('目标标题。正文') + self.card('目标标题加长版。正文')) + self.assertEqual(note.note_link_candidate(self.b,'目标标题',click=True)['status'],'ambiguous') + self.assertFalse(self.b.eval('!!document.body.dataset.clicked')) + + def test_visible_candidate_disappears_then_returns_during_search(self): + card = self.card('目标标题。正文') + self.open(card) + # 复现旧代码第一次检查成功、随后搜索结果替换使第二次检查为空。 + self.assertEqual(note.note_link_candidate(self.b,'目标标题')['status'],'unique') + self.b.eval(f'''(() => {{ + document.body.innerHTML=''; + setTimeout(() => {{document.body.innerHTML={json.dumps(card)};}}, 2000); + return true; + }})()''') + self.assertEqual(note.note_link_candidate(self.b,'目标标题')['status'],'missing') + note.wait_note_edit(self.b,'目标标题',timeout=10) + self.assertEqual(self.b.eval('document.body.dataset.clicked'),'1') + + def test_missing_edit_or_title_does_not_click(self): + for html in ('
目标标题。正文
', + self.card('其他标题'), '
目标标题
编辑作品'): + with self.subTest(html=html): + self.open(html) + self.assertEqual(note.note_link_candidate(self.b,'目标标题',click=True)['status'],'missing') + self.assertFalse(self.b.eval('!!document.body.dataset.clicked')) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/skills/test_publish_metrics.py b/test/skills/test_publish_metrics.py new file mode 100644 index 00000000..89beace6 --- /dev/null +++ b/test/skills/test_publish_metrics.py @@ -0,0 +1,282 @@ +import importlib.util +import json +import io +from contextlib import redirect_stdout +from pathlib import Path +import sqlite3 +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[2] +TOOLS = ROOT/'crews/main/skills/expert-douyin/tools' + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +note = load('note_publish', TOOLS/'douyin-note-publish/scripts/publish_douyin_note.py') +status = load('platform_status', ROOT/'crews/main/skills/published-track/scripts/platform-status.py') + + +class NoteTests(unittest.TestCase): + def setUp(self): + sleeper = patch.object(note.time, 'sleep') + sleeper.start() + self.addCleanup(sleeper.stop) + + def test_validate_before_browser(self): + with patch.object(note, 'Browser') as browser: + self.assertEqual(note.main(['run','--original-sound','--images','missing.png','--title','X']),1) + browser.return_value.command.assert_not_called() + with self.assertRaises(ValueError): + note.validate(title='中'*21) + with self.assertRaises(ValueError): + note.validate(images=['x']*36,title='X') + + def test_link_requires_image_edit_url_and_preserves_id(self): + b=Mock() + def evaluate(js): + if js == 'window.location.href': + return 'https://creator.douyin.com/creator-micro/content/post/image?mid=7687034742688058662&enter_from=edit_item' + if 'const result={titles:' in js: + return {'status':'unique','titles':1,'actions':1} + if 'return inputs.length===1 && inputs[0].value' in js: + return {'title':'标题'} + return True + b.eval.side_effect=evaluate + result=note.get_note_link(b,'标题') + self.assertEqual(result['url'],'https://www.douyin.com/note/7687034742688058662') + self.assertIn(unittest.mock.call('reload'),b.command.call_args_list) + self.assertFalse(any(c.args[0] == 'fill' for c in b.command.call_args_list)) + self.assertIn(unittest.mock.call('press', 'Enter'), b.command.call_args_list) + + def test_link_rejects_editor_title_mismatch(self): + b=Mock() + def evaluate(js): + if js == 'window.location.href': + return 'https://creator.douyin.com/creator-micro/content/post/image?mid=7687034742688058662' + if 'return inputs.length===1 && inputs[0].value' in js: + return {'title':'标题加长版'} + return True + b.eval.side_effect=evaluate + with patch.object(note,'note_link_candidate',return_value={'status':'unique'}): + with self.assertRaisesRegex(RuntimeError,'标题不一致'): + note.get_note_link(b,'标题') + + def test_link_ambiguity_never_clicks(self): + b=Mock() + b.eval.return_value=True + with patch.object(note,'check_login'), patch.object(note,'fill_input'), \ + patch.object(note,'note_link_candidate',return_value={'status':'ambiguous','titles':2,'actions':2}) as candidate: + with self.assertRaisesRegex(RuntimeError,'多个标题前缀候选'): + note.get_note_link(b,'标题') + candidate.assert_called_once_with(b,'标题',click=True) + + def test_link_research_is_bounded_and_never_publishes(self): + b=Mock() + b.eval.return_value=True + def wait(browser, action, message, timeout=60): + if not action(): + raise note.WaitTimeout(message) + return True + with patch.object(note,'check_login'), patch.object(note,'fill_input'), \ + patch.object(note,'wait_for',side_effect=wait), \ + patch.object(note,'note_link_candidate',return_value={'status':'missing','titles':0,'actions':0}), \ + patch.object(note,'publish') as publish: + with self.assertRaisesRegex(RuntimeError,'重新搜索4次'): + note.get_note_link(b,'标题') + self.assertEqual(b.command.call_args_list.count(unittest.mock.call('press','Enter')),4) + publish.assert_not_called() + + def test_link_recovers_after_search_refresh(self): + b=Mock() + def evaluate(js): + if js == 'window.location.href': + return 'https://creator.douyin.com/creator-micro/content/post/image?mid=7687034742688058662' + if 'return inputs.length===1 && inputs[0].value' in js: + return {'title':'标题'} + return True + b.eval.side_effect=evaluate + def wait(browser, action, message, timeout=60): + result=action() + if not result: + raise note.WaitTimeout(message) + return result + with patch.object(note,'wait_for',side_effect=wait), \ + patch.object(note,'note_link_candidate',side_effect=[ + {'status':'missing'}, {'status':'unique'}]) as candidate: + self.assertEqual(note.get_note_link(b,'标题')['mid'],'7687034742688058662') + self.assertEqual(b.command.call_args_list.count(unittest.mock.call('press','Enter')),2) + self.assertEqual(candidate.call_args_list[-1],unittest.mock.call(b,'标题',click=True)) + + def test_candidate_missing_during_list_replacement_is_retried(self): + b=Mock() + with patch.object(note,'check_login'), patch.object(note,'note_link_candidate',side_effect=[ + {'status':'missing','titles':0,'actions':0}, + {'status':'missing','titles':0,'actions':0}, + {'status':'unique','titles':1,'actions':1}]) as candidate: + note.wait_note_edit(b,'标题') + self.assertEqual(candidate.call_count,3) + self.assertTrue(all(call.kwargs == {'click':True} for call in candidate.call_args_list)) + + def test_click_transport_error_is_not_retried(self): + b=Mock() + with patch.object(note,'check_login'), patch.object(note,'note_link_candidate', + side_effect=RuntimeError('browser transport failed')) as candidate: + with self.assertRaisesRegex(RuntimeError,'browser transport failed'): + note.wait_note_edit(b,'标题') + candidate.assert_called_once() + + def test_fill_uses_eval_and_stops_on_rejected_input(self): + b = Mock() + b.eval.side_effect = ['https://creator.douyin.com/creator-micro/content/post/image', False] + with self.assertRaisesRegex(RuntimeError, '读回不一致'): + note.fill(b, '标题', '描述', 'none') + b.command.assert_not_called() + + def test_fill_stops_when_controlled_input_reverts(self): + b = Mock() + b.eval.side_effect = [True, False] + with self.assertRaisesRegex(RuntimeError, '更新后读回不一致'): + note.fill_input(b, 'input', '标题') + + def test_fill_failure_stops_before_publish_and_closes(self): + with patch.object(note,'validate'), patch.object(note,'Browser') as browser, \ + patch.object(note,'upload'), patch.object(note,'fill',side_effect=RuntimeError('wrong music')), \ + patch.object(note,'publish') as publish: + self.assertEqual(note.main(['run','--original-sound','--images','x.png','--title','X']),1) + publish.assert_not_called() + browser.return_value.close.assert_called_once() + + def test_link_failure_never_republishes(self): + with patch.object(note,'validate'), patch.object(note,'Browser') as browser, \ + patch.object(note,'upload'), patch.object(note,'fill'), patch.object(note,'publish') as publish, \ + patch.object(note,'get_note_link',side_effect=RuntimeError('ambiguous title')): + self.assertEqual(note.main(['run','--original-sound','--images','x.png','--title','X']),3) + publish.assert_called_once() + browser.return_value.close.assert_called_once() + + def test_login_required_before_fill_does_not_modify_form(self): + b=Mock() + b.eval.return_value='https://creator.douyin.com/login?redirect=upload' + with self.assertRaises(note.LoginRequired): + note.fill(b,'标题','正文') + b.command.assert_not_called() + + def test_wait_detects_login_redirect_without_waiting_for_timeout(self): + b=Mock() + b.eval.side_effect=['https://creator.douyin.com/creator-micro/content/upload', + 'https://creator.douyin.com/login'] + action=Mock(return_value=False) + with patch.object(note.time,'sleep'): + with self.assertRaises(note.LoginRequired): + note.wait_for(b,action,'timeout') + action.assert_called_once() + + def test_login_word_in_query_is_not_logout(self): + b=Mock() + b.eval.return_value='https://creator.douyin.com/creator-micro/content/upload?from=/login' + note.check_login(b) + + def test_login_failure_run_stops_and_reports_publish_stage(self): + for after_publish in (False,True): + with self.subTest(after_publish=after_publish), patch.object(note,'validate'), \ + patch.object(note,'Browser') as browser, patch.object(note,'upload') as upload, \ + patch.object(note,'fill') as fill, patch.object(note,'publish') as publish, \ + patch.object(note,'get_note_link') as link: + if after_publish: + link.side_effect=note.LoginRequired('SESSION_EXPIRED') + else: + upload.side_effect=note.LoginRequired('SESSION_EXPIRED') + output=io.StringIO() + with redirect_stdout(output): + code=note.main(['run','--original-sound','--images','x.png','--title','标题']) + self.assertEqual(code,2) + result=json.loads(output.getvalue()) + self.assertEqual(result['error'],'SESSION_EXPIRED') + self.assertEqual(result['publish_attempted'],after_publish) + self.assertEqual(publish.call_count,1 if after_publish else 0) + if not after_publish: + fill.assert_not_called() + browser.return_value.close.assert_called_once() + + def test_music_choice_must_come_from_current_page(self): + b = Mock() + b.eval.side_effect = ['https://creator.douyin.com/creator-micro/content/post/image', None] + with self.assertRaisesRegex(RuntimeError, '候选已失效'): + note.select_music(b, 'invented-song') + self.assertEqual(b.eval.call_count, 2) + b.command.assert_not_called() + + def test_publish_blocks_missing_or_mismatched_music(self): + for selected in (None, {'name': '实际候选'}): + with self.subTest(selected=selected): + b = Mock() + b.eval.side_effect = ['https://creator.douyin.com/creator-micro/content/post/image', selected] + with patch.object(note, 'verify_music', return_value=False), patch.object(note, 'click_text') as click: + with self.assertRaisesRegex(RuntimeError, '尚未确认配乐'): + note.publish(b) + click.assert_not_called() + + def test_upload_waits_for_all_images_before_returning(self): + b = Mock() + b.eval.side_effect = [ + 'https://creator.douyin.com/creator-micro/content/upload', + 'https://creator.douyin.com/creator-micro/content/upload', True, + 'https://creator.douyin.com/creator-micro/content/upload', True, + 'https://creator.douyin.com/creator-micro/content/post/image', False, + 'https://creator.douyin.com/creator-micro/content/post/image', True, + ] + with patch.object(note.time, 'sleep') as sleep: + note.upload(b, ['one.png', 'two.png']) + sleep.assert_called_once() + self.assertIn('已添加2张图片', b.eval.call_args.args[0]) + + def test_upfront_music_and_implicit_original_sound_rejected(self): + parser = note.build_parser() + for args in (['upload', '--images', 'x.png', '--music', 'song'], + ['run', '--images', 'x.png', '--title', 'X']): + with self.subTest(args=args), self.assertRaises(SystemExit): + parser.parse_args(args) + + def test_shared_lock_fail_first(self): + with note.publish_lock(): + with self.assertRaisesRegex(RuntimeError,'正忙'): + with note.publish_lock(): + pass + + +class MetricsTests(unittest.TestCase): + def test_platform_status(self): + with tempfile.TemporaryDirectory() as folder: + workspace=Path(folder) + self.assertFalse(status.platform_status(workspace,'douyin')['enabled']) + directory=workspace/'douyin/calibration' + directory.mkdir(parents=True) + legacy=directory/'.platform-state.json' + legacy.write_text('{"enabled":true}') + self.assertTrue(status.platform_status(workspace,'douyin')['enabled']) + current=directory/'platform-state.json' + current.write_text('{"enabled":false}') + self.assertFalse(status.platform_status(workspace,'douyin')['enabled']) + current.write_text('{"enabled":"true"}') + self.assertFalse(status.platform_status(workspace,'douyin')['ok']) + current.write_text('invalid') + self.assertFalse(status.platform_status(workspace,'douyin')['ok']) + + def test_note_and_video_extraction(self): + script=ROOT/'crews/main/skills/published-track/scripts/fetch-and-update-metrics.sh' + text=script.read_text() + func=text[text.index('extract_content_id()'):text.index('# ─── 平台配置')] + for kind in ('note','video'): + proc=subprocess.run(['bash','-c',func+'\nextract_content_id douyin "$1"','test', + f'https://www.douyin.com/{kind}/7687034742688058662?x=1'],capture_output=True,text=True) + self.assertEqual(proc.stdout.strip(),'7687034742688058662') + +if __name__=='__main__': + unittest.main() diff --git a/test/skills/test_publish_xhs.py b/test/skills/test_publish_xhs.py new file mode 100644 index 00000000..0eda9f84 --- /dev/null +++ b/test/skills/test_publish_xhs.py @@ -0,0 +1,74 @@ +"""Regression tests for publish_xhs body newline normalization.""" + +from __future__ import annotations + +import importlib.util +import io +import sys +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "crews/main/skills/expert-xhs/tools/xhs-publish/scripts/publish_xhs.py" +SPEC = importlib.util.spec_from_file_location("publish_xhs", SCRIPT_PATH) +assert SPEC and SPEC.loader +publish_xhs = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(publish_xhs) + + +class NormalizeBodyNewlinesTests(unittest.TestCase): + def test_converts_literal_backslash_n_to_real_newline(self) -> None: + # bash 双引号里 --body "第一行\n第二行" 收到的就是字面量反斜杠+n + self.assertEqual( + publish_xhs.normalize_body_newlines("第一行\\n第二行"), + "第一行\n第二行", + ) + + def test_converts_literal_crlf_without_leaving_stray_cr(self) -> None: + self.assertEqual(publish_xhs.normalize_body_newlines("a\\r\\nb"), "a\nb") + + def test_keeps_real_newlines_untouched(self) -> None: + self.assertEqual(publish_xhs.normalize_body_newlines("a\nb"), "a\nb") + + def test_handles_mixed_literal_and_real_newlines(self) -> None: + self.assertEqual(publish_xhs.normalize_body_newlines("a\nb\\nc"), "a\nb\nc") + + def test_normalizing_before_extraction_keeps_topic_names_clean(self) -> None: + # 字面量 \n 是非空白字符:不先归一化,extract_topics 会把 "职场\n第二行" 整个当话题名 + raw = "#职场\\n第二行正文" + topics = publish_xhs.extract_topics( + publish_xhs.normalize_body_newlines(raw), None + ) + self.assertEqual([t["name"] for t in topics], ["职场"]) + + +class MainBodyNormalizationTests(unittest.TestCase): + def test_main_publishes_body_with_real_newlines(self) -> None: + # 端到端守住 main() 的接线:字面量 \n 的 --body 到达发布函数时已是真实换行 + argv = [ + "publish_xhs.py", + "--mode", "image", + "--title", "标题", + "--body", "第一行\\n第二行", + "--images", "img.jpg", + ] + captured: dict = {} + + def fake_publish(client, cookie_dict, ua, title, body, images, topics, private): + captured["body"] = body + return {"ok": True, "note_id": "x", "url": "u"} + + with ( + mock.patch.object(sys, "argv", argv), + mock.patch.object(publish_xhs, "load_cookies", return_value=({}, "UA")), + mock.patch.object(publish_xhs, "publish_image_note", side_effect=fake_publish), + mock.patch.object(sys, "stdout", new=io.StringIO()), + ): + publish_xhs.main() + + self.assertEqual(captured["body"], "第一行\n第二行") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/skills/test_tts.py b/test/skills/test_tts.py new file mode 100644 index 00000000..12188438 --- /dev/null +++ b/test/skills/test_tts.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Unit tests for tts.py (多供应商 TTS 路由 + 百炼 payload 映射). + +Covers: +- 供应商路由优先级:火山凭据 → 百炼业务空间 → 百炼 agent plan → die +- 百炼 payload:格式别名 ogg_opus→opus、语速/响度线性映射、instruction、火山音色换默认 +- 相似度:清洗标点空白后序敏感比对(中文无空格不再归零) +- ASR 自检后端选择标记 + +All network calls avoided — pure unit tests. +""" +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] / 'skills/awk-tts/scripts' +sys.path.insert(0, str(SCRIPTS_DIR)) + +import tts # noqa: E402 + +KEYS = ( + "VOLC_TTS_APP_ID", "VOLC_TTS_ACCESS_KEY", "VOLC_TTS_APP_KEY", + "WORKSPACE_ID", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "AWK_API_KEY", +) + + +def _env(**overrides): + base = {k: v for k, v in os.environ.items() if k not in KEYS} + base.update({k: v for k, v in overrides.items() if v is not None}) + return base + + +class TestProviderRouting(unittest.TestCase): + def test_volc_priority(self): + with mock.patch.dict(os.environ, _env( + VOLC_TTS_APP_ID="1", VOLC_TTS_ACCESS_KEY="t", + WORKSPACE_ID="llm-x", MODELSTUDIO_API_KEY="sk-w", AWK_API_KEY="sk-sp", + ), clear=True): + self.assertEqual(tts.resolve_tts_provider(), "volc") + + def test_volc_single_header(self): + with mock.patch.dict(os.environ, _env(VOLC_TTS_APP_KEY="k"), clear=True): + self.assertEqual(tts.resolve_tts_provider(), "volc") + + def test_bailian_workspace(self): + with mock.patch.dict(os.environ, _env( + WORKSPACE_ID="llm-x", MODELSTUDIO_API_KEY="sk-w" + ), clear=True): + provider = tts.resolve_tts_provider() + self.assertEqual(provider[0], "bailian") + self.assertEqual(provider[3], "workspace") + self.assertIn("llm-x.cn-beijing.maas.aliyuncs.com", provider[1]) + + def test_bailian_agent_plan(self): + with mock.patch.dict(os.environ, _env(AWK_API_KEY="sk-sp-x"), clear=True): + provider = tts.resolve_tts_provider() + self.assertEqual(provider[0], "bailian") + self.assertEqual(provider[3], "agent-plan") + self.assertIn("token-plan", provider[1]) + + def test_no_credentials_dies(self): + with mock.patch.dict(os.environ, _env(), clear=True): + with self.assertRaises(SystemExit): + tts.resolve_tts_provider() + + +class TestBailianPayload(unittest.TestCase): + def _args(self, **overrides): + defaults = dict( + voice=None, format="mp3", sample_rate=None, speech_rate=None, + loudness_rate=None, context_text=None, model=None, enable_subtitle=False, + ) + defaults.update(overrides) + return mock.Mock(**defaults) + + def test_defaults(self): + payload = tts.build_bailian_payload(self._args(), "你好", "qwen-audio-3.0-tts-plus") + self.assertEqual(payload["model"], "qwen-audio-3.0-tts-plus") + inp = payload["input"] + self.assertEqual(inp["text"], "你好") + self.assertEqual(inp["voice"], tts.BAILIAN_DEFAULT_VOICE) + self.assertEqual(inp["format"], "mp3") + self.assertEqual(inp["sample_rate"], 24000) + self.assertNotIn("rate", inp) + self.assertNotIn("volume", inp) + + def test_format_alias_ogg_opus(self): + payload = tts.build_bailian_payload(self._args(format="ogg_opus"), "x", "m") + self.assertEqual(payload["input"]["format"], "opus") + + def test_speech_rate_mapping(self): + # 100 → 2.0, -50 → 0.5, 0 → 1.0 + for rate, expected in ((100, 2.0), (-50, 0.5), (0, 1.0), (50, 1.5)): + payload = tts.build_bailian_payload(self._args(speech_rate=rate), "x", "m") + self.assertEqual(payload["input"]["rate"], expected) + + def test_loudness_mapping(self): + # 0 → 50, 100 → 100, -50 → 25 + for loud, expected in ((0, 50), (100, 100), (-50, 25)): + payload = tts.build_bailian_payload(self._args(loudness_rate=loud), "x", "m") + self.assertEqual(payload["input"]["volume"], expected) + + def test_context_text_maps_to_instruction(self): + payload = tts.build_bailian_payload(self._args(context_text="用撒娇的语气"), "x", "m") + self.assertEqual(payload["input"]["instruction"], "用撒娇的语气") + + def test_volc_voice_swapped_to_bailian_default(self): + payload = tts.build_bailian_payload( + self._args(voice="zh_female_shuangkuaisisi_uranus_bigtts"), "x", "m" + ) + self.assertEqual(payload["input"]["voice"], tts.BAILIAN_DEFAULT_VOICE) + + def test_bailian_voice_passthrough(self): + payload = tts.build_bailian_payload(self._args(voice="my_cloned_voice"), "x", "m") + self.assertEqual(payload["input"]["voice"], "my_cloned_voice") + + +class TestSimilarity(unittest.TestCase): + def test_chinese_punctuation_difference_passes(self): + # 旧 jaccard 按空白分词此处会归零 + sim = tts.similarity_ratio( + "火山链路回归测试确认默认路径未变", + "火山链路回归测试,确认默认路径未变。", + ) + self.assertGreater(sim, 0.9) + + def test_identical(self): + self.assertEqual(tts.similarity_ratio("abc def", "abc def"), 1.0) + + def test_empty(self): + self.assertEqual(tts.similarity_ratio("", "abc"), 0.0) + self.assertEqual(tts.similarity_ratio("", ""), 1.0) + + def test_asr_typo_still_passes_threshold(self): + # 实测场景:ASR 把"字级"听成"字集",单字差异仍应过 0.5 阈值 + sim = tts.similarity_ratio( + "大家好这是百炼语音合成链路的实测包含字集时间戳", + "大家好,这是百炼语音合成链路的实测,包含字级时间戳。", + ) + self.assertGreater(sim, 0.5) + + +class TestSubtitleSchema(unittest.TestCase): + def test_bailian_sentence_to_volc_schema(self): + raw = { + "text": "大家好", + "words": [ + {"text": "大", "begin_time": 160, "end_time": 240}, + {"text": "家", "begin_time": 240, "end_time": 400}, + ], + } + converted = tts._bailian_stream_sentences(raw) + self.assertEqual(converted["text"], "大家好") + self.assertEqual(converted["words"][0], {"word": "大", "startTime": 0.16, "endTime": 0.24}) + self.assertEqual(converted["phonemes"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/crews/main/skills/expert-xhs/tools/xhs-engagement/scripts/test_xhs_engagement.py b/test/skills/test_xhs_engagement.py similarity index 96% rename from crews/main/skills/expert-xhs/tools/xhs-engagement/scripts/test_xhs_engagement.py rename to test/skills/test_xhs_engagement.py index f72281e9..5e220443 100644 --- a/crews/main/skills/expert-xhs/tools/xhs-engagement/scripts/test_xhs_engagement.py +++ b/test/skills/test_xhs_engagement.py @@ -9,7 +9,7 @@ from unittest import mock -SCRIPT_PATH = Path(__file__).with_name("xhs_engagement.py") +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "crews/main/skills/expert-xhs/tools/xhs-engagement/scripts/xhs_engagement.py" SPEC = importlib.util.spec_from_file_location("xhs_engagement", SCRIPT_PATH) assert SPEC and SPEC.loader xhs_engagement = importlib.util.module_from_spec(SPEC) diff --git a/test/test-douyin-metrics.mjs b/test/test-douyin-metrics.mjs new file mode 100644 index 00000000..b1dc8230 --- /dev/null +++ b/test/test-douyin-metrics.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +// Run the actual fetch → argument conversion → SQLite pipeline with mocked HTTP +// adapters in an isolated workspace. Never read live cookies or write the live DB. +const root = mkdtempSync(join(tmpdir(), 'douyin-metrics-')); +const source = new URL('../crews/main/skills/published-track/scripts/', import.meta.url); +const scripts = join(root, 'skills/published-track/scripts'); +const run = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +try { + mkdirSync(scripts, { recursive: true }); + mkdirSync(join(root, 'skills/_shared'), { recursive: true }); + mkdirSync(join(root, 'logins')); + writeFileSync(join(root, 'logins/douyin.json'), JSON.stringify({ cookies: [{ name: 'sessionid', value: 'test' }] })); + for (const file of ['init-db.sh', 'update-metrics.sh', 'fetch-and-update-metrics.sh']) { + copyFileSync(new URL(file, source), join(scripts, file)); + } + writeFileSync(join(scripts, 'fetch-retro-data.ts'), readFileSync(new URL('fetch-retro-data.ts', source), 'utf8') + .replace('join(homedir(), ".openclaw", "logins")', JSON.stringify(join(root, 'logins')))); + writeFileSync(join(scripts, 'check-login.ts'), 'console.log(JSON.stringify({ok:true}))'); + writeFileSync(join(root, 'skills/_shared/douyin-web.ts'), ` +import {readFileSync} from 'node:fs'; +const fixture = JSON.parse(readFileSync(${JSON.stringify(join(root, 'fixture.json'))}, 'utf8')); +export async function douyinWebGet() { return {status:200,data:{aweme_detail:{statistics:fixture.public}}}; } +export async function douyinCreatorItem() { return fixture.creator === null ? null : {metrics:fixture.creator}; } +`); + run('bash', [join(scripts, 'init-db.sh')]); + const db = join(root, 'db/published_track.db'); + const sql = text => run('sqlite3', ['-json', db, text]); + sql("INSERT INTO pub_douyin(id,title,content_type,source_folder,publish_url,publish_date) VALUES(1,'test','post','test','https://www.douyin.com/note/123','2026-09-19');"); + const fetch = fixture => { + writeFileSync(join(root, 'fixture.json'), JSON.stringify(fixture)); + run('bash', [join(scripts, 'fetch-and-update-metrics.sh'), '--platform', 'douyin', '--id', '1']); + return JSON.parse(sql('SELECT plays,likes,comments,shares,favorites,deep_metrics FROM pub_douyin WHERE id=1'))[0]; + }; + let row = fetch({ public: { play_count: 0, digg_count: 99 }, creator: { + view_count: 187, like_count: 7, comment_count: 2, share_count: 3, favorite_count: 1, + completion_rate: 0.35, danmaku_count: 4, subscribe_count: 2, + }}); + assert.deepEqual(row, { plays:187, likes:7, comments:2, shares:3, favorites:1, + deep_metrics: JSON.stringify({completion_rate:0.35, danmaku_count:4, subscribe_count:2}) }); + row = fetch({ public: { digg_count: 99 }, creator: {view_count:0,like_count:0,comment_count:0,share_count:0,favorite_count:0} }); + assert.deepEqual(row, {plays:0,likes:0,comments:0,shares:0,favorites:0,deep_metrics:'{}'}); + sql('UPDATE pub_douyin SET plays=55,likes=1,comments=6,shares=8,favorites=9'); + row = fetch({public:{play_count:0,digg_count:3},creator:null}); + assert.equal(row.plays,55); + assert.equal(row.likes,3); + assert.equal(row.comments,6); + assert.equal(row.shares,8); + assert.equal(row.favorites,9); + row = fetch({public:{digg_count:4,comment_count:2},creator:{view_count:60,like_count:null,share_count:-1}}); + assert.equal(row.likes,4); + assert.equal(row.comments,2); + assert.equal(row.shares,8); + assert.equal(row.plays,60); + assert.equal(row.deep_metrics,'{}'); + console.log('PASS: creator metrics, deep separation, zero updates, missing/invalid values, public fallback'); +} finally { + rmSync(root, {recursive:true,force:true}); +} diff --git a/test/vitest.config.mts b/test/vitest.config.mts new file mode 100644 index 00000000..78c31482 --- /dev/null +++ b/test/vitest.config.mts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; + +export default { + root: fileURLToPath(new URL("../", import.meta.url)), + resolve: { + alias: [{ + find: /^openclaw\/plugin-sdk\/(.+)$/, + replacement: fileURLToPath(new URL("../openclaw/dist/plugin-sdk/", import.meta.url)) + "$1.js", + }], + }, + test: { + include: ["test/camoufox-cli/**/*.test.ts", "test/awada/**/*.test.ts"], + }, +}; diff --git a/tests/test-docker-layout.sh b/tests/test-docker-layout.sh deleted file mode 100755 index 4f2ae531..00000000 --- a/tests/test-docker-layout.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -contains() { - local file="$1" pattern="$2" - rg -Fq -- "$pattern" "$file" || fail "$file must contain: $pattern" -} - -not_contains() { - local file="$1" pattern="$2" - if rg -Fq -- "$pattern" "$file"; then - fail "$file must not contain: $pattern" - fi -} - -# The image must build the same application tree that install.sh configures, -# rather than maintaining a second, hand-written installer in Dockerfile. -contains Dockerfile "COPY . /opt/xiaobei" -contains Dockerfile "scripts/docker-bootstrap.sh" -contains Dockerfile "/opt/xiaobei/runtime-seed/openclaw" -not_contains Dockerfile "wiseflow-client" - -# A first launch needs one user-supplied secret only. All mutable data belongs -# to named volumes whose names are part of the public deployment contract. -contains docker-compose.yml "AWK_API_KEY: \${AWK_API_KEY:?" -contains docker-compose.yml "xiaobei-openclaw:/root/.openclaw" -contains docker-compose.yml "xiaobei-camoufox:/root/.camoufox-cli" -not_contains docker-compose.yml "OFB_KEY" -not_contains docker-compose.yml "wiseflow-" - -# Entrypoint must restore an empty volume from the baked seed and must never -# bake a predictable gateway credential into the image or command line. -contains docker-entrypoint.sh "/opt/xiaobei/runtime-seed/openclaw" -contains docker-entrypoint.sh "OPENCLAW_GATEWAY_TOKEN" -not_contains docker-entrypoint.sh "wiseflow-gateway-token" - -# Production publishing uses the same public xiaobei image name. -contains .github/workflows/release.yml "/xiaobei" - -echo "PASS: Docker deployment layout"