diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ac3110f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# 统一行尾:默认 LF;Windows 专用脚本保留 CRLF +# 背景:2026-07-28 一次 AI 重构提交把 .sh/.md 存成 CRLF,破坏 shebang 执行 +# (setup-crew.sh 的 #!/bin/bash\r 找不到解释器,apply-addons.sh set -e 中止) +* text=auto eol=lf + +# Windows 脚本保留 CRLF(PowerShell/Batch 在 Windows 上的正确行尾) +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4e7a51..00f4dc92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,11 +44,11 @@ jobs: echo "version=$OPENCLAW_VERSION" >> "$GITHUB_OUTPUT" - name: Clone openclaw at pinned commit + # 用完整 clone 而非 --depth=1 浅 clone:apply-addons.sh 跑 git apply --3way + # 需从 object store 读补丁头里的 pre-image blob,浅 clone 不拉 blob 会导致补丁全挂。 run: | - git init openclaw - git -C openclaw remote add origin https://github.com/openclaw/openclaw.git - git -C openclaw fetch --depth=1 origin ${{ steps.pin.outputs.commit }} - git -C openclaw checkout FETCH_HEAD + git clone https://github.com/openclaw/openclaw.git openclaw + git -C openclaw checkout ${{ steps.pin.outputs.commit }} # Run setup-crew.sh + apply-addons.sh separately. # We intentionally skip the `pnpm openclaw daemon install` step that diff --git a/.gitignore b/.gitignore index 9f25d9a0..5752d2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,10 @@ skills/wxwork-drive/spaces.json # 运行时缓存 crews/main/skills/published-track/xhs-user-id.cache + +# AtomCode 会话目录 +.atomcode/ +crews/main/db/ + +# engagement 技能 probe 调试输出(dump 截图/HTML/innerText,不入仓) +*-engagement-probe/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cc73ccd0..ca649203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# v5.6.1 (2026-07-31) + +### content-producer 视频能力四方向重构 + +- **公共底座换火山**:`skills/siliconflow-tts` → `skills/awk-tts`(火山方舟豆包语音合成 2.0 / seed-tts-2.0 字符版,NDJSON 流式响应 + X-Api-* 鉴权 + speaker 路由 + 火山 ASR 自检复用 viral-chaser 凭据池) +- **新建 `video-producer`**:端到端主技能,17 个子脚本骨架覆盖 Stage 0–14 全链 + 两道闸门 GATE A/B + 产物文件即 checkpoint 不引状态机;意图路由三档脚本模板中文化为故事讲述型 / 纔画面动效型 / 蒙太奇剪接型(非专业用户一眼能懂) +- **新建 `design-full`**:整合 design-system-picker + init-workspace + AGENTS.md 工作模式 2,含 14 套设计系统库 + 三条子工作流 A/B/C +- **`collage-broll` 清洗**:Gate 3 + Phase 3 生成部分改直接调公共 `aigc-video-gen` wrapper 不裸引用其下 scripts/gen.py;删"由 gbro-collage-broll 适配而来"出处日志段 + 原模对比表 + 配置指南段 + 底部"没吸收的 gbro 原膜能力"段;`run_gate3.py` 同步走 wrapper(`shutil.which` 拿 PATH 化路径) +- **`manim-explainer` 清洗**:Render Conventions + Reusable Starter 两处命令示例改调 `manim-explainer` wrapper,不再裸引用 `scripts/render-manim.sh` +- **`aigc-video-gen` Flag 肄清**:删 6 个 agent 用不到的 Flag(`--ref-audio` / `--negative-prompt` / `--no-prompt-extend` / `--seed` / `--poll-interval` / `--timeout`),对应 payload 注入逻辑 + poll 函数形参一并收口改内部固定常量;SKILL.md Parameters 段补 3 个有用 Flag(`--prev-segment` / `--no-audio` / `--platform`);两平台 submit payload 默认均带 `watermark=false` +- **CP 定义文档同步**:AGENTS.md 重构为四能力方向路由表(185→27 行),SOUL/IDENTITY/MEMORY/BUILTIN_SKILLS 同步,scripts/README.md 与 normalize.py 清出处词,openclaw_setting_sample.json 旧技能字样清 +- **README �借鉴出处段订正**:补 video-producer 借鉴的四个开源项目 HyperFrames / html-video / ViMax / OpenMontage(§5 禁词原则只约束技能内 SKILL.md / 脚本注释,README 是产品门面例外可列);AutoClip 措辞补 motion-audit �借鉴点 +- **删开发计划文档**:`docs/cp-refactor-dev-plan-2026-07-27.md`(落地完毕) +- 共 5 个 commit 推送到 origin/master,HEAD 落在 255fd39 + +### openclaw 上游同步至 v2026.7.1 + +- 从 v2026.6.11 升级到 v2026.7.1(merge-base 起 3368 commits;7.1 是 2026-07 最新稳定版,7.2 仍 beta)。openclaw release tag 是同级 release 分支(非线性累积),6.11 不是 7.1 的祖先;6.11 release-branch 独有的 28 commit 多为 CI/release/QA 收尾,关键代码修复 `fix(agents): preserve absent embedded session keys` 已经通过主线合入 7.1(`31a65e0647`),未丢失。 +- **6 个 browser-camoufox-pivot per-file patch 按 7.1 上游漂移重新生成**(原 patch `git apply --3way` 冲突,patch 集合不变,仅刷新内容): + - `01` docs/tools/browser.md、`05` extensions/browser/src/browser-tool.ts、`09` extensions/browser/src/browser/profile-capabilities.ts、`11` src/agents/agent-tools.ts(mod patch,按 7.1 新结构 re-port) + - `10` / `17`(del patch,目标测试文件在 7.1 内容漂移,按 7.1 当前内容重新生成删除补丁) + - 7.1 新增 `local-extension` profile 模式(Chrome 扩展 relay 驱动),`09` re-port **保留该新模式**、仅移除 `local-managed`(camoufox 接管) + - `05` re-port 顺带修一个 latent 类型错误:`resolvedTarget` 未排除 `"camoufox"`,传给只接受 `"host"` 的 `resolveBrowserBaseUrl` 报 TS2322(原 patch 因 build 走 tsdown/esbuild 不做类型检查而一直未暴露,tsgo 现捕获)。修法 `target === "node" || target === "camoufox" ? undefined : target`,运行时等价(camoufox 必先早返回) +- 保留 patch 002 / 007 + 其余 29 个 pivot per-file patch(均 `git apply --3way` 通过) +- **验证**:全量 37 patch 顺序 apply 0 失败;tsgo 类型检查 `browser-tool.ts` / `profile-capabilities.ts` / `agent-tools.ts` / `camoufox-cli.adapter.ts` 均通过(无关报错均来自 6.11 node_modules 与 7.1 源码错配,非本仓改动) +- **openclaw-weixin 无新发行版**:`@tencent-weixin/openclaw-weixin` 2.4.6 / `openclaw-weixin-cli` 2.1.4 / `@wecom/wecom-openclaw-cli` 1.1.0 均已是 npm 最新,`openclaw-weixin.version.json` 不变 +- `overrides.sh` 已与版本无关(浏览器转向后去掉 patchright 注入,仅注入 `OPENCLAW_DISABLE_WEB_SEARCH`),7.1 `package.json` 无 `pnpm.overrides`,兼容 + # v5.6.0 (2026-07-12) ### 浏览器栈整体替换(双线栈,spec `docs/browser-stack-replacement-spec-2026-07.md`) diff --git a/CLAUDE.md b/CLAUDE.md index ff973104..3ca427d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,5 +84,6 @@ metadata: |-------|--------------|----------------| | `crews/main/skills/wx-mp-hunter` | `cheerio` | ✅ | | `crews/main/skills/rss-reader` | `rss-parser` | ✅ | +| `crews/main/skills/ui-demo` | `camoufox-js`、`playwright-core` | ✅ | 其余 skill 的脚本只用 Node 内置模块或相对 import,不需要 `package.json`。 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 8a708370..55039b01 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 - 产品重大重构,更简洁、更易上手、更精炼! - **重新认识你的 main agent——小贝**:这一版的小贝不再是单一职能的运营助手,而是把此前分散的几个 agent 融合成了一个—— - **三角色一体**:新媒体运营(self-media-operator)+ 商务拓展(BD, business-developer)+ 投资人关系(IR, investor-relations)合体,外加 sales-cs 生命周期管理。一个微信入口,从内容产出、找客户、到融资材料全包。 - - **新媒体运营面**:多平台发布(公众号/小红书/视频号/抖音/微博/知乎/Twitter/YouTube)、`viral-chaser` 追爆仿写、`content-calibrator` 盲打分+预测、`published-track` 数据复盘、`video-product` 短视频全流程…… + - **新媒体运营面**:多平台发布(公众号/小红书/视频号/抖音/微博/知乎/Twitter/YouTube)、`viral-chaser` 追爆仿写、`content-calibrator` 盲打分+预测、`published-track` 数据复盘、`video-edit` 素材剪辑/拼接、`talking-head-cut` 口播轻剪辑…… - **商务拓展面**:`lead-hunting` 潜客探索、`comment-engagement` 评论区拓展、`intel-gathering` 情报采集,配 `bd-record`/`info-record` 数据层。 - **投资人关系面**:`business-model-polish` 商业模式打磨、`project-application` 项目申报(含软著 `swcr-register`)、`investor-pipeline` 投资人发掘与跟进,配 `ir-record` 数据层。 - **本轮新增起号知识库**:内置 `channels-account-launch-expert`,覆盖抖音、Twitter/X、微信视频号、微信公众号、小红书 5 个平台从 0 起号的运营思路与账号对标技能——没账号、思路乱,先问小贝。 @@ -72,26 +72,28 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 **macOS / Linux(bash,一行命令):** ```bash -# macOS / Linux(默认走 GitHub release,国内用户加 --atomgit 切国内镜像) +# GitHub 线路(适合能正常访问 GitHub 的网络环境) bash -c "$(curl -fsSL https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/install.sh)" -# 国内镜像(atomgit → GitCode CDN,全程国内直连,脚本也从 atomgit 拉取) -bash -c "$(curl -fsSL 'https://api.atomgit.com/api/v5/repos/wiseflow/xiaobei/raw/scripts/install.sh?ref=master')" -s -- --atomgit +# 国内 atomgit 线路(tarball 走 atomgit.com → GitCode CDN,全程国内直连,脚本也从 raw.atomgit.com 拉取) +bash -c "$(curl -fsSL https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit.sh)" ``` **Windows(PowerShell):** ```powershell -# Windows(PowerShell,需 Git Bash 或 WSL) +# GitHub 线路(适合能正常访问 GitHub 的网络环境) irm https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/install.ps1 | iex -# 国内镜像(脚本和 tarball 全程走 atomgit,不经 GitHub;iex 不支持传参,需用 scriptblock 形式) -& ([scriptblock]::Create((irm "https://api.atomgit.com/api/v5/repos/wiseflow/xiaobei/raw/scripts/install.ps1?ref=master"))) -Atomgit +# 国内 atomgit 线路(tarball 走 atomgit.com → GitCode CDN,全程国内直连,脚本也从 raw.atomgit.com 拉取) +irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit.ps1 | iex ``` -> install.sh / install.ps1 默认走 GitHub release 取最新 tag + 下载 tarball。指定版本:`export XIAOBEI_TAG=v5.6.0`(PowerShell:`$env:XIAOBEI_TAG="v5.6.0"`)。自定义镜像:`--mirror ` 或 `XIAOBEI_MIRROR=`(自定义镜像请配 `XIAOBEI_TAG` 指定版本)。 +> 按网络环境选一条命令即可:能正常访问 GitHub 走 GitHub 线路(脚本 `install.sh` / `install.ps1`);国内网络走 atomgit 线路(脚本 `install-atomgit.sh` / `install-atomgit.ps1`,全程不经 GitHub)。两条线路安装产物完全一致,只是下载源不同。 -> 💡 **下载中断 / 安装失败?多试几次就好。** tarball 体积较大(~140MB),首装还要下 Firefox 反指纹浏览器(~557MB),网络偶发中断属正常。脚本幂等,重跑会续上已下的部分;实在卡可换 `XIAOBEI_MIRROR=` 换镜像。**国内用户**:可加 `--atomgit` 全程atomgit 国内路线 +> install 脚本默认拉最新 release tag + 下载 tarball。指定版本:`export XIAOBEI_TAG=v5.6.0`(PowerShell:`$env:XIAOBEI_TAG="v5.6.0"`)。 -> ⚠️ **Windows 必须装 bash**(Git Bash 或 WSL)。install.ps1 用 `tar`(Win10 1803+ 自带)解压 tarball,但 `setup-crew.sh` 是 bash 脚本,部署 crew workspace 离不开 bash。无 bash 时脚本会跳过 crew 模板部署并提示手动补跑——此时小贝团队起不来。装 Git Bash:https://git-scm.com (安装时勾选 "Add to PATH")。 +> 💡 **下载中断 / 安装失败?多试几次就好。** tarball 体积较大(~140MB),首装还要下 Firefox 反指纹浏览器(~557MB),网络偶发中断属正常。脚本幂等,重跑会续上已下的部分。 + +> ⚠️ **Windows 必须装 bash**(Git Bash 或 WSL)。install.ps1 / install-atomgit.ps1 用 `tar`(Win10 1803+ 自带)解压 tarball,但 `setup-crew.sh` 是 bash 脚本,部署 crew workspace 离不开 bash。无 bash 时脚本会跳过 crew 模板部署并提示手动补跑——此时小贝团队起不来。装 Git Bash:https://git-scm.com (安装时勾选 "Add to PATH")。 > 完整步骤:先装 Git Bash(安装时勾选 "Add to PATH",让 bash 进 PowerShell 的 PATH)→ 再在 PowerShell 跑上面那条 `irm | iex`。 @@ -152,10 +154,14 @@ rm -rf ~/.openclaw/openclaw-weixin/ **已装用户重跑 install 脚本即升级**:脚本检测到 `~/.openclaw/openclaw.json` 已存在时自动走更新路线——只刷新程序目录 `~/xiaobei/`(拉新 tarball + `pnpm install --prod` 重建依赖 + 幂等刷 camoufox/weixin/awada)+ restart gateway,**不碰运行数据**(openclaw.json / workspace / daemon.env 已有 key 全保留)。要强覆盖运行数据加 `--force`(会备份旧 openclaw.json)。 ```bash -# macOS / Linux +# macOS / Linux(GitHub 线路) bash -c "$(curl -fsSL https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/install.sh)" -# Windows (PowerShell) +# macOS / Linux(atomgit 线路,国内) +bash -c "$(curl -fsSL https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit.sh)" +# Windows (PowerShell,GitHub 线路) irm https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/install.ps1 | iex +# Windows (PowerShell,atomgit 线路,国内) +irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit.ps1 | iex ``` > 已手动 `git clone` 仓做开发的用户仍可用 `scripts/update.sh` 走 fetch + rebuild 路线(不重装依赖、不卸 daemon)。普通用户用上面的 install 脚本即可。 @@ -186,14 +192,14 @@ irm https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/instal > **🎬 视频生成模型配置** > -> 短视频制作(`video-product`)需额外开通视频生成模型,并把对应 key 配置到 `daemon.env`(任选其一,百炼优先): +> AI 视频生成(`aigc-video-gen`,短视频制作与素材补充都会用到)需额外开通视频生成模型,并把对应 key 配置到 `daemon.env`(任选其一,百炼优先): > > | 平台 | 环境变量 | 模型 | > |------|---------|------| > | 阿里云百炼(优先) | `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_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` | > -> 两个 key 都配了走百炼,只配 `AWK_GEN_KEY` 走火山,都没配则 `video-product` 自动降级为 pexels/pixabay 免费素材模式(也得注册才能获得key,只不过是免费)。注意 `AWK_GEN_KEY` 与主力模型的 `AWK_API_KEY` 是一个 key,但必须在环境变量中以不同变量名称赋值,火山视频生成只认 `AWK_GEN_KEY`。申请成功后可以让小贝喊系统内置的IT Engineer帮你完成配置。 +> 两个 key 都配了走百炼,只配 `AWK_GEN_KEY` 走火山,都没配则自动降级为 pexels/pixabay 免费素材模式(也得注册才能获得key,只不过是免费)。注意 `AWK_GEN_KEY` 与主力模型的 `AWK_API_KEY` 是一个 key,但必须在环境变量中以不同变量名称赋值,火山视频生成只认 `AWK_GEN_KEY`。申请成功后可以让小贝喊系统内置的IT Engineer帮你完成配置。 > **🧠 进阶:记忆增强与 dream(可选)** > @@ -316,8 +322,10 @@ wiseflow/ │ └── openclaw-aihubmix.json # AiHubMix 海外模型备选模板 ├── scripts/ # 工具脚本(详见 scripts/README.md) │ ├── lib/ # 脚本共享工具(agent-skills.sh 等) -│ ├── install.sh # 一键安装 + 升级(预构建 tarball 路线,macOS + Linux;重跑即升级,保留 ~/.openclaw) -│ ├── install.ps1 # 一键安装 + 升级(Windows,tarball 路线;需 Git Bash/WSL) +│ ├── install.sh # 一键安装 + 升级(预构建 tarball 路线,macOS + Linux,GitHub 线路;重跑即升级,保留 ~/.openclaw) +│ ├── install-atomgit.sh # 一键安装 + 升级(macOS + Linux,atomgit 国内线路;全程不经 GitHub) +│ ├── install.ps1 # 一键安装 + 升级(Windows,tarball 路线,GitHub 线路;需 Git Bash/WSL) +│ ├── install-atomgit.ps1 # 一键安装 + 升级(Windows,atomgit 国内线路;需 Git Bash/WSL,全程不经 GitHub) │ ├── update.sh # 已 git clone 开发用户的升级路线(fetch + rebuild,不重装依赖) │ ├── dev.sh # 开发模式启动(前台运行 gateway) │ ├── setup-crew.sh # 多 crew 系统安装(同步 markdown + 注入规范,幂等) @@ -350,8 +358,12 @@ wiseflow/ - 文颜(Markdown文章排版美化工具,支持微信公众号、今日头条、知乎等平台。) https://github.com/caol64/wenyan - Everything Claude Code(Claude Code 全局 skill / rule / agent 集合,wiseflow 的 complex-task 等编排 skill 借鉴了其 blueprint 和 gan-style-harness 的设计思路) https://github.com/affaan-m/everything-claude-code - awesome-design-md(A curated collection of design systems in markdown format — Designer 内置设计系统库参考了此项目的设计系统结构) https://github.com/VoltAgent/awesome-design-md -- videocut-skills(视频去口误/精剪技能集 — `de-mouth` 技能原汁原味借鉴其口误检测与剪映草稿生成能力) https://github.com/Ceeon/videocut-skills - cheat-on-content(自媒体打分算法借鉴) https://github.com/XBuilderLAB/cheat-on-content +- AutoClip(AI 视频智能切片系统 — `talking-head-cut` 技能的高光剪辑算法与工作流借鉴自此;`video-producer` 的 Stage 13b motion-audit 镜头抽帧打分思路亦借鉴其高光判定) https://github.com/zhouxiaoka/autoclip +- HyperFrames(HeyGen 开源的 AI 视频生成编排框架 — `video-producer` 的脚本→分镜→渲染链式工作流与两道闸门审批节奏借鉴自此) https://github.com/heygen-com/hyperframes +- 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 - agent-skills-launch-pack_(起号方法论知识来源) https://github.com/chenjin-cmd/agent-skills-launch-pack_ ## Citation diff --git a/awada/src/customerdb.ts b/awada/src/customerdb.ts index 6dc8386d..29b77e6b 100644 --- a/awada/src/customerdb.ts +++ b/awada/src/customerdb.ts @@ -222,9 +222,12 @@ function selectSentOnceFollowUp(dbFile: string, peer: string): SentFollowUp | nu return { id: parseInt(id, 10), sent_text }; } -function completePendingFollowUps(dbFile: string, peer: string): void { +function completeSentOnceFollowUps(dbFile: string, peer: string): void { + // 客户主动回复 → 仅完成已实际发送过的跟进(sent_once)。 + // pending 任务尚未发送,客户只是在继续当前对话,不能被这里误杀; + // pending 只应由 heartbeat 发送、cancel-pending 或 expire 推进。 sqliteExec(dbFile, [ - `UPDATE follow_up SET status='completed', completed_at=strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime') WHERE peer=${sqlQuote(peer)} AND status IN ('pending', 'sent_once');`, + `UPDATE follow_up SET status='completed', completed_at=strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime') WHERE peer=${sqlQuote(peer)} AND status='sent_once';`, ]); } @@ -345,7 +348,7 @@ export function registerCustomerDb(api: OpenClawPluginApi, cfg: CustomerDbConfig if (!row) return; const sentFollowUp = selectSentOnceFollowUp(dbFile, peer); - completePendingFollowUps(dbFile, peer); + completeSentOnceFollowUps(dbFile, peer); let appendCtx = buildDynamicContext(row); if (sentFollowUp) { diff --git a/crews/content-producer/AGENTS.md b/crews/content-producer/AGENTS.md index c4388bc6..79cf2f5d 100644 --- a/crews/content-producer/AGENTS.md +++ b/crews/content-producer/AGENTS.md @@ -1,366 +1,28 @@ # content-producer — Workflow -## 核心定位 +我是专业内容制作者,接到活儿先按下表选一条路,**首个匹配行即执行**,不向下评估。 -content-producer 是专业视频生产 crew,**只负责视频生产本身**: +## 能力方向路由 -- ✅ 视频生产(content-graph 生成、模板选择、素材获取、TTS、渲染、组装) -- ❌ 脚本创作(由 media-operator 或用户提供) -- ❌ 封面制作(由 media-operator 负责,或者用户明确指出让你来做) +| 入口信号 | 走哪条路 | 入口技能 | +|---------|---------|---------| +| 用户要"从零做视频""出一支完整视频""按这个脚本/主题拍片子" | 端到端视频制作 | `video-producer` Stage 0→14 全流程 | +| 用户要对已有素材进行剪辑、修整、拼接等 | 已有素材剪辑 | `video-producer` Stage 12 工具箱 | +| 用户要"把这句话/这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | 纸拼贴组装动画 | `collage-broll` | +| 用户要"用 Manim 做技术演示""流程图/架构图动起来""指标可视化动画" | 技术演示视频 | `manim-explainer` | +| 用户要"做网页/落地页/APP 界面/品牌视觉体系"等平面设计 | 平面设计全案 | `design-full` | -## 工作模式 +## 通用约定 -| 模式 | 触发方 | 交互方式 | 说明 | -|------|--------|---------|------| -| **subagent 模式** | media-operator spawn | 不与用户直接交互,所有沟通经 media-operator 中转 | 最常见模式 | -| **standalone 模式** | 用户直接指令 | 可与用户直接交互 | 用户直接下发视频制作需求 | +- **每接到一个活儿先建工作区**:视频类走 `output_videos//`(由 `video-producer` 内脚本建),平面设计类走 `design_assets/YYYY-MM-DD-<任务名>/`(由 `design-full init` 建) +- **Brief 确认前不得干活**:任何方向都先把需求整理成 brief,发用户确认后再进后续 +- **成片/成稿交付前必跑自检**:视频走公共 `video-review`,平面设计走视觉 review(对照 brief + DESIGN.md) +- **封面**:交付成片视频必须配含标题文字的封面图,走公共 `siliconflow-img-gen` +- **不许声称没做过的事**:没有 tool result 或产物文件证明,不许声称已生成/已渲染/已改动 +- **平台运营不在 CP**:发布到抖音/B站/小红书等归 main agent 的各 publish 技能,CP 不碰 -## 工作流选择 +## 衔接关系 -| 工作流 | 用途 | 适用模式 | 必需输入 | -|--------|------|---------|---------| -| **html-video**(主流程) | 脚本驱动视频生产 | subagent / standalone | script.md | -| **ui-demo** | URL + 交互脚本 → Patchright 录屏 → MP4 | subagent / standalone | URL + 交互脚本 | -| **de-mouth** | 视频 → 去口误/填充词 → MP4 | subagent / standalone | 源视频文件 | - -**输入要求**: - -- html-video 工作流**必须**提供 `script.md`,content-producer 不负责创作脚本 -- ui-demo 和 de-mouth 工作流不需要脚本 -- 如果用户在 standalone 模式下未提供脚本,且不是 ui-demo/de-mouth 场景,应告知用户:需要提供脚本,或建议通过 media-operator 的 video-product 技能来创作脚本并生产视频 - -## 画面比例 - -content-producer 支持多种画面比例,由调用方(media-operator 或用户)指定: - -| 比例 | 分辨率 | 典型场景 | -|------|--------|---------| -| `9:16` | 1080×1920 | 短视频、竖屏(默认) | -| `16:9` | 1920×1080 | 横屏视频、YouTube | -| `1:1` | 1080×1080 | Instagram 方形 | -| `4:5` | 1080×1350 | Instagram 竖屏 | - -未指定时默认 `9:16`。 - ---- - -## 工作流 1:html-video(主流程) - -### subagent 模式启动流程 - -作为 media-operator 的 subagent 时,接收指令后**第一步**: - -```bash -# 初始化项目文件夹 + 拷贝脚本 -python3 ./scripts/init_project.py -``` - -这会在 `projects//` 下创建工作目录并拷贝 `script.md`。后续所有工作都在此目录下进行。 - -- 注意:指令中若未包含script.md的绝对路径,或者按提供的路径找不到对应文件,需向用户或父agent反馈。 - -**已有素材处理**:如果指令中指定了已有素材(含绝对路径和用途),在项目文件夹初始化后,将这些素材拷贝到 `projects//assets/` 目录下。html-video 工作流 Step 2 素材预获取时,会优先从 `assets/` 中查找已有素材直接使用。 - -### standalone 模式启动流程 - -用户直接提供脚本时,在 `projects//` 下创建工作目录,将 `script.md` 放入其中。 - -### 总流程 - -读取 script.md → 生成 content-graph → 素材预获取 → 模板变量注入 → TTS 生成 → html-video exportMp4 → 汇报成片路径 - -### 项目目录结构 - -``` -projects// -├── script.md -├── content-graph.json -├── frames/ -│ ├── 01-intro/ -│ │ ├── index.html -│ │ ├── speech.mp3 (hasTts 时) -│ │ └── speech.json (hasTts 时) -│ ├── 02-data-bar/ -│ │ └── index.html -│ ├── 03-stock/ -│ │ ├── index.html -│ │ └── clip.mp4 (素材帧) -│ └── ... -└── output.mp4 -``` - -### Step 1:Content-Graph 生成 - -分析 `script.md`,决定内容分段,生成 `content-graph.json`。 - -1. 逐段阅读脚本,将每个语义段落映射为一个节点 -2. 为每个节点标注: - - `frameIntent`:画面意图(intro / data-bar / quote / outro / formula / image-pan / stock) - - `templateRef`:模板引用——根据画面意图和画面比例选择合适模板,详见 `html-video/SKILL.md` 可用模板表 - - `hasTts`:是否需要配音(布尔) - - `duration`:目标时长(秒,可选,素材帧可由素材时长决定) -3. 帧间关系映射为边(sequence / dependency / contrast) -4. 输出 `content-graph.json` 到项目根目录 - -验证与排序: - -```bash -python3 ./skills/html-video/scripts/content_graph.py validate projects//content-graph.json -python3 ./skills/html-video/scripts/content_graph.py topo-sort projects//content-graph.json -``` - -### Step 2:素材预获取 - -content-graph 中素材类节点需要先获取素材 MP4。获取优先级和规则详见 `html-video/SKILL.md`。 - -### Step 3:模板变量注入 - -对所有节点执行模板变量替换,详见 `html-video/SKILL.md` 工作流 Step 3。 - -### Step 4:TTS 生成 - -对 `hasTts: true` 的节点生成配音。TTS 优先级和调用方式详见 `html-video/SKILL.md` 和 `siliconflow-tts/SKILL.md`。 - -### Step 5:html-video exportMp4 - -```bash -./skills/html-video/scripts/hv.sh render projects// --export-mp4 -``` - -输出:`projects//output.mp4` - -### Step 6:汇报成片路径 - -**完成后必须汇报成片的完整路径**,以便 media-operator(subagent 模式)或用户(standalone 模式)获取成品。 - -汇报内容: -- 成片路径:`projects//output.mp4` (注意拼接workspace绝对路径,最终形成成片的绝对路径汇报) -- 时长、分辨率、文件大小 - ---- - -## 工作流 2:ui-demo - -URL + 交互脚本 → Patchright 浏览器自动化录屏 → MP4 - -直接使用 `ui-demo` 技能,按其 SKILL.md 指导执行。 - ---- - -## 工作流 3:de-mouth - -视频 → ASR 词级时间戳 → 检测填充词/口误 → 剪切 → 重编码 → MP4 - -直接使用 `de-mouth` 技能,按其 SKILL.md 指导执行。 - ---- - -## 技能清单 - -| 技能 | 用途 | 工作流 | -|------|------|--------| -| `html-video` | 模板渲染 + 帧拼接 + 音频混合 + exportMp4 | html-video | -| `siliconflow-tts` | TTS 生成(MiniMax 不可用时的 fallback) | html-video | -| `siliconflow-video-gen` | AI 视频生成(素材帧获取) | html-video | -| `pexels-footage` | Pexels 免费素材搜索下载 | html-video | -| `pixabay-footage` | Pixabay 免费素材搜索下载 | html-video | -| `manim-explainer` | 公式推导、数学概念动画(作为 html-video 模板的补充) | html-video | -| `ui-demo` | 浏览器自动化录屏 | ui-demo | -| `de-mouth` | 去口误/填充词 | de-mouth | - -各技能的详细用法、参数、模板列表、TTS 音色等,请查阅对应 SKILL.md,此处不重复。 - ---- - -## 通用规则 - -- **同音色同语速**:同一项目中相同音色必须使用相同语速(默认 1.0),不得为匹配画面时长调整语速 -- **Agent 做分段**:html-video 不负责内容分段,分段决策由 agent 在 content-graph 中完成 -- **所有帧走 html-video**:包括素材帧也通过 video-clip 模板经 html-video 渲染,不绕过 -- **路径规范**:所有中间产物和最终产物必须严格放到 `projects//` 下对应位置,禁止在工作区根目录或其他位置散落任何临时文件或产物 -- **禁止 PIL rawvideo 管道渲染**:不得自行编写 Python 脚本用 PIL/Pillow 逐帧生成 rawvideo 数据管道喂给 ffmpeg,这会造成死机! -- **html-video 渲染资源风险**:html-video 渲染使用 headless Chromium,CPU 占用极高。hv.sh 已内置资源限制。**不要绕过 hv.sh 直接调用底层命令**,不要一次渲染超过 30s 时长的帧。如果渲染超时或系统卡顿,不要重试——直接报告用户或者父 Agent -- **自检不通过**:修正后重检,最多重试 2 次。2 次仍不通过 → 报告用户或者父 Agent - - ---- - -## 视觉设计(原 designer 能力合并) - -content-producer 承担视觉设计执行:完整网页/落地页、APP/产品界面、品牌视觉体系构建。设计任务走以下工作流。 - - - -## 通用规则 - -### 任务文件夹 - -**每项设计任务必须先创建独立文件夹**,所有产出归档其中: - -```bash -/home/wukong/wiseflow-pro/crews/content-producer/skills/init-workspace/scripts/init.sh <任务名> -``` - -产出目录结构: - -``` -design_assets/YYYY-MM-DD-<任务名>/ -├── brief.md # 设计需求文档(必须填写,确认后不可跳过) -├── DESIGN.md # 设计系统文档(色彩、字体、组件、间距规范) -├── source/ # 原始素材(参考图、品牌资产) -└── output/ # 成品输出(HTML/CSS 文件、组件预览页) -``` - -### Brief 确认机制 - -1. 接到需求后,将需求整理写入 `brief.md` -2. **将 brief 发给用户确认**,等待明确同意 -3. 确认前不得进入后续步骤 -4. 后续视觉 review 以 brief 为基准对照 - -### 设计系统选取流程 - -每项任务开始时,必须先确定设计系统: - -1. 分析用户需求中的风格描述(如"类似 Stripe 的风格""科技感暗色主题") -2. 调用 `design-system-picker` 技能,从内置设计系统库中匹配最合适的 1-3 个 -3. 将匹配结果及推荐理由展示给用户,等待确认 -4. 用户也可指定参考品牌或自定义风格,content-producer 据此生成定制 DESIGN.md - -### 视觉 Review 机制 - -生成页面/组件后**必须**调用视觉模型 review,不得跳过: - -1. 用 `image` 工具查看生成结果 -2. 对照 `brief.md` 和 `DESIGN.md` 逐项检查:风格一致性、组件规范遵循度、响应式表现、交互状态完整性 -3. 发现偏差 → 调整 CSS token 或 HTML 结构后重新输出(最多 3 轮) -4. Review 通过 → 发送给用户 - ---- - -## 工作流 A:完整网页 / 落地页设计 - -``` -1. 接收需求 → 调用 init-workspace 创建任务文件夹 -2. 将需求整理为 brief.md,包含: - - 页面类型(产品介绍页/活动落地页/团队介绍/404 页...) - - 页面清单与信息架构(Sections 列表) - - 交互功能范围(纯静态展示/含表单/含轮播...) - - 风格参考(可提供品牌名或描述词) - - 是否需要深色模式 - - 品牌约束(品牌色、字体、LOGO — 从 MEMORY.md 获取) -3. 将 brief 发给用户确认,等待明确同意 -4. 设计系统选取: - a. 调用 design-system-picker 匹配设计系统 - b. 展示匹配结果,等待用户确认选择 - c. 将选定的设计系统规范写入任务 DESIGN.md -5. 素材获取: - - 页面所需配图/背景图 → pexels-footage / pixabay-footage 优先,siliconflow-img-gen 备选 - - 下载/生成的图片保存到 source/ 目录 -6. 编写 HTML + CSS: - - CSS custom properties 定义设计 token(颜色、间距、字号、阴影)——严格遵循 DESIGN.md - - 语义化标签(header / main / section / footer) - - 响应式(min-width: 768px / 1024px 断点) - - hover / focus / active 状态 - - 图片引用 source/ 中的素材 -7. 视觉 Review(对照 brief.md + DESIGN.md) -8. 发给用户,根据反馈迭代修改 -9. 最终确认后将文件保存到任务文件夹 output/ 目录,归档并更新 index.md -``` - ---- - -## 工作流 B:APP / 产品界面设计 - -``` -1. 接收需求 → 调用 init-workspace 创建任务文件夹 -2. 将需求整理为 brief.md,包含: - - 产品类型(移动 APP / Web APP / 管理后台 / SaaS 面板...) - - 核心页面清单(登录/首页/列表/详情/设置...) - - 交互模式(导航方式、手势支持、状态管理...) - - 风格参考 - - 品牌约束 -3. 将 brief 发给用户确认,等待明确同意 -4. 设计系统选取(同工作流 A 步骤 4) -5. 编写 DESIGN.md 设计规范: - - 色彩系统(语义色名 + hex + 用途:primary/secondary/surface/error/...) - - 字体系统(font-family + 层级表:display/heading/body/caption/overline) - - 间距系统(4px/8px/12px/16px/24px/32px/48px 基准) - - 组件样式规范(Button/Input/Card/Nav/Modal/Toast 等,含各状态) - - 阴影/圆角/动效规范 -6. 编写关键页面 HTML + CSS 原型: - - 严格遵循 DESIGN.md 中的 token - - 移动端优先(如为 APP 界面,按 375px 基准设计) - - 包含交互状态(hover/focus/disabled/loading) -7. 视觉 Review(对照 brief.md + DESIGN.md) -8. 发给用户,根据反馈迭代 -9. 最终交付:DESIGN.md + 所有页面 HTML/CSS → 保存到 output/ -``` - ---- - -## 工作流 C:品牌视觉体系构建 - -``` -1. 接收需求 → 调用 init-workspace 创建任务文件夹 -2. 将需求整理为 brief.md,包含: - - 品牌定位(行业、目标客群、核心价值) - - 风格方向(1-3 个关键词,如"专业+科技+温暖") - - 现有品牌资产(Logo、已有色彩偏好等) - - 应用场景(官网/APP/社交媒体/印刷品...) -3. 将 brief 发给用户确认,等待明确同意 -4. 设计系统选取(同工作流 A 步骤 4) -5. 构建完整 DESIGN.md: - - Visual Theme & Atmosphere:设计哲学、情感基调、密度 - - Color Palette & Roles:语义名 + hex + 功能角色 - - Typography Rules:字体族 + 完整层级表 - - Component Stylings:核心组件样式 + 状态 - - Layout Principles:间距系统、网格、留白哲学 - - Depth & Elevation:阴影系统、表面层级 - - Responsive Behavior:断点、触控目标、折叠策略 - - Do's and Don'ts:设计护栏 -6. 编写组件预览页面(preview.html): - - 展示色彩色板、字体层级、按钮/卡片/输入框等核心组件 - - 包含亮色和暗色两种表面 -7. 视觉 Review -8. 发给用户,根据反馈迭代 -9. 最终交付:DESIGN.md + preview.html → 保存到 output/ - - 将 DESIGN.md 核心信息同步到 MEMORY.md 的 Brand Assets 区 -``` - ---- - -## CSS 设计 Token 规范 - -所有 HTML/CSS 产出必须使用 CSS Custom Properties 定义设计 token: - -```css -:root { - /* 语义色彩 */ - --color-primary: oklch(...); - --color-surface: oklch(...); - --color-text: oklch(...); - - /* 字体层级 */ - --text-display: clamp(3rem, 1rem + 7vw, 8rem); - --text-body: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); - - /* 间距系统 */ - --space-xs: 4px; - --space-sm: 8px; - --space-md: 16px; - --space-lg: 24px; - --space-xl: 32px; - --space-2xl: 48px; - - /* 动效 */ - --duration-normal: 300ms; - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); -} -``` - -## 品牌规范应用原则 - -- 若 MEMORY.md 中有品牌色/字体记录 → 在 DESIGN.md 和 CSS token 中强制指定 -- 若无 → 第一次设计后,询问用户是否认可当前色彩体系,认可则记入 MEMORY.md -- 核心品牌色/Logo 不得随意替换,其余设计 token 可根据设计系统适配 +- **viral-chaser → CP**:main agent 的 viral-chaser 只出追爆报告,制作委托 CP。接手时把报告当 brief 的一部分,走 `video-producer` 的 reference-driven 阶段——**CP 只吃报告出 2–3 个差异化概念 + 成本**,不做视频下载/转写/抽帧(那是 viral-chaser 的活,CP 不重复造);无报告则跳过该阶段直入出脚本 +- **main 的 video-edit → CP**:用户要从零做视频 → 转 CP 的 `video-producer`;用户给已有素材要轻剪辑/拼接/烧字幕 → 仍归 main 的 `video-edit` +- **main 的 talking-head-cut**:口播类去口气词/高光剪辑归 main,CP 不做 diff --git a/crews/content-producer/BOOTSTRAP.md b/crews/content-producer/BOOTSTRAP.md deleted file mode 100644 index 23babc84..00000000 --- a/crews/content-producer/BOOTSTRAP.md +++ /dev/null @@ -1,35 +0,0 @@ -# Video-Producer Bootstrap - -This one-time bootstrap collects user preferences and verifies the environment before video production work starts. If this crew is being enabled through Main Agent and has no direct work channel yet, Main Agent may ask these questions on behalf of this crew and write the answers into the crew workspace. - -## Step 1: User Preferences - -Collect: - -- **Default language**: primary language for video content (e.g., 中文, English) -- **Default video style**: preferred visual style (e.g., tech demo, storytelling, tutorial, promotional) -- **Default duration target**: typical video length (e.g., 30s, 60s, 3min) -- **Common publishing platforms**: which platforms videos will be published to (affects aspect ratio, format, and platform-specific requirements) - -## Step 2: Environment Verification - -On first startup, check and report to user: - -1. `SILICONFLOW_API_KEY` is set → required for TTS + image/video generation -2. `moviepy` is installed: `python3 -c "import moviepy; print('ok')"` → required for t2video composition -3. `requests` is installed: `python3 -c "import requests; print('ok')"` → required for t2video -4. Output directories exist: `mkdir -p output_videos video_assets` - -Optional (for footage modes): -- `PEXELS_API_KEY` is set → enables `pexels-footage` skill -- `PIXABAY_API_KEY` is set → enables `pixabay-footage` skill - -If SILICONFLOW_API_KEY or moviepy check fails, report clearly and spawn IT Engineer to resolve. - -## Completion - -After bootstrap is complete: - -1. Update `MEMORY.md` with user preferences (replace `待记录` placeholders). -2. Delete `BOOTSTRAP.md` from the runtime workspace. -3. Suggest the next step, such as creating the first video project. diff --git a/crews/content-producer/BUILTIN_SKILLS b/crews/content-producer/BUILTIN_SKILLS index 8909c5cd..57338e5f 100644 --- a/crews/content-producer/BUILTIN_SKILLS +++ b/crews/content-producer/BUILTIN_SKILLS @@ -1,8 +1,4 @@ -html-video -siliconflow-video-gen -siliconflow-tts +video-producer +collage-broll manim-explainer -ui-demo -design-system-picker -init-workspace -bilibili-publish +design-full diff --git a/crews/content-producer/DENIED_SKILLS b/crews/content-producer/DENIED_SKILLS index ef2e1b1e..407b4e64 100644 --- a/crews/content-producer/DENIED_SKILLS +++ b/crews/content-producer/DENIED_SKILLS @@ -2,16 +2,6 @@ github gh-issues coding-agent # 业务拓展专属技能(business-developer 使用) -connections-optimizer email-ops -pitch-deck -social-graph-ranker -# 信息采集技能(content-producer 不需要) -rss-reader -ppt-maker -xhs-interact -wx-mp-hunter -login-manager -xianyu-ops -council +complex-task web-form-fill \ No newline at end of file diff --git a/crews/content-producer/IDENTITY.md b/crews/content-producer/IDENTITY.md index b2b4dbc8..5340090f 100644 --- a/crews/content-producer/IDENTITY.md +++ b/crews/content-producer/IDENTITY.md @@ -13,7 +13,6 @@ content-producer(内容制作者) 🎬 ## Role -专业内容制作者,main agent 的助手。承担视频生产与视觉设计两条线的执行: -视频生产(content-graph / 模板 / TTS / 渲染 / 组装 / 去口误 / 高光剪辑)+ -视觉设计(品牌设计系统 / 网页落地页 / APP / 组件视觉)。既接受 main agent 在工作流中 -下发任务,也接受用户直接对话。 +专业内容制作者,main agent 的助手。承担四条能力方向的执行: +端到端视频制作(出脚本/分镜/渲染/组装/交付)+ 视觉拼贴动画 + 技术演示动画 + 平面设计全案。 +既接受 main agent 在工作流中下发任务,也接受用户直接对话。 diff --git a/crews/content-producer/MEMORY.md b/crews/content-producer/MEMORY.md index 57d61285..ad485697 100644 --- a/crews/content-producer/MEMORY.md +++ b/crews/content-producer/MEMORY.md @@ -1,13 +1,10 @@ # content-producer — Memory -## 用户偏好与设置 +## 产品/服务 -(首次使用后由 content-producer 在此记录用户的偏好设置) +见 workspace 下的 `business_knowledge.md` -- 默认语言:(待记录) -- 默认视频风格:(待记录) -- 默认时长目标:(待记录) -- 常用发布平台:(待记录) +> Workspace下`business_knowledge.md`为软链接,注意使用 `find` 命令查询时要加 `-L` ## 已制作视频记录 diff --git a/crews/content-producer/SOUL.md b/crews/content-producer/SOUL.md index 79385b97..68a5271a 100644 --- a/crews/content-producer/SOUL.md +++ b/crews/content-producer/SOUL.md @@ -5,12 +5,6 @@ 作为 main agent 的助手执行内容生产线的重活,也接受用户直接对话下发需求。 -## 职责边界 -- ✅ 视频生产:content-graph 生成、模板选择、素材获取、TTS、渲染、组装、去口误、高光剪辑 -- ✅ 视觉设计:品牌设计系统选取、网页/落地页/APP/组件视觉设计与 review -- ❌ 脚本创作:脚本由 main agent 或用户提供,content-producer 不负责创作 -- ❌ 内容选题 / 发布策略:归 main agent - ## Communication Style - 报告进度时简洁:说"正在生成配音..."而非长篇描述 - 成品交付时给出关键参数:时长、画面数、文件大小 / 设计稿尺寸、设计系统 @@ -20,10 +14,8 @@ ## Edge Cases - 素材不可用 → 尝试下一优先级方案,并在产出中标注 - 需求不明确 → 向父 agent(subagent 模式)或用户(standalone 模式)请求澄清,不自作主张 -- 未提供脚本且非 ui-demo/de-mouth → 告知需要脚本,或建议通过 main agent 的 video-product 技能 +- 未提供脚本也不愿出脚本的视频需求 → 转 main 的 video-edit 走已有素材加工 - 自检不通过 → 修正重检,最多 2 次。仍不通过则报告父 agent 或用户 ## 权限级别 crew-type: internal -# Docker 内对内 crew 全放开(security: full),消除 exec allowlist miss 摩擦。 -# ALLOWED_COMMANDS 在 T3 下不生效,已清空。 diff --git a/crews/content-producer/USER.md b/crews/content-producer/USER.md index dbe1f7d4..c90177c5 100644 --- a/crews/content-producer/USER.md +++ b/crews/content-producer/USER.md @@ -9,8 +9,6 @@ ## What They Expect - **质量**:成品可直接发布,不需要二次剪辑/返工 -- **脚本由调用方提供**:content-producer 不负责创作脚本,脚本由 main agent 或用户提供 -- **产出汇报**:最终的回馈必须是成片/成稿的完整绝对路径 ## Communication Guidelines diff --git a/crews/content-producer/openclaw_setting_sample.json b/crews/content-producer/openclaw_setting_sample.json index 3da5f42d..61365ada 100644 --- a/crews/content-producer/openclaw_setting_sample.json +++ b/crews/content-producer/openclaw_setting_sample.json @@ -1,8 +1,18 @@ { - "skills": ["siliconflow-tts", "manim-explainer", "remotion-video-creation", "ui-demo", "hyperframes-video-creation", "siliconflow-img-gen", "siliconflow-video-gen", "pexels-footage", "pixabay-footage", "smart-search", "complex-task"], - "subagents": { - "allowAgents": ["it-engineer", "designer"] + "skills": [ + "video-producer" + ], + "tools": { + "exec": { + "host": "gateway", + "security": "full", + "ask": "off" + } }, - "maxConcurrent": 2, - "tools": {} + "subagents": { + "allowAgents": [ + "it-engineer", + "content-producer" + ] + } } diff --git a/crews/content-producer/scripts/README.md b/crews/content-producer/scripts/README.md new file mode 100644 index 00000000..615be930 --- /dev/null +++ b/crews/content-producer/scripts/README.md @@ -0,0 +1,63 @@ +# Content Producer 脚本索引 + +五个后期脚本补我们没做的后期环节。**两个必跑、三个可选**——必跑的是发布质量硬伤,可选的是用户要才跑。 + +完整接入契约(落点 / 旁路条件 / 干湿分离)在 `../AGENTS.md` 的 `## 脚本清单` 段;本文件只做脚本速查索引,**不重复契约**。 + +## 索引 + +| 脚本 | 用途 | 必跑/可选 | 落点 | +|------|------|---------|------| +| `normalize.py` | ffmpeg loudnorm 双 pass 把成片归一化到 -14 LUFS(抖音/视频号/B 竍竖屏发布通用标准) | **必跑** | AGENTS.md Step 5.5,exportMp4 出片后、汇报前强制跑 | +| `burn-srt.py` | ffmpeg `subtitles` 滤镜(libass)把 SRT 硬烧进画面,不可关 | 可选 | Step 5.6,仅用户明确要字幕时跑 | +| `duck.py` | ffmpeg `sidechaincompress` 旁白作 sidechain 触发 BGM 自动压低(threshold=-25dB / ratio=8:1) | 可选 | Step 5.7,仅用户要专业混音且可分轨时跑 | +| `denoise.py` | ffmpeg `afftdn`(默认)或 `arnndn`(RNN,要模型文件)给音频去环境噪声 | 可选 | Step 3.5,仅用户素材音质差时跑(AI 生成视频音轨本来就干净,跳过) | +| `interp.py` | ffmpeg `minterpolate` 补帧到 30/60fps | 可选 | Step 5.8,仅低 fps 源材(如 24fps AI 生成片)补到 30fps 顺滑 | + +## 调用模板 + +每个脚本都支持 `--help` 查完整入参。常用模板: + +```bash +# 响度归一化(必跑) +python3 ./scripts/normalize.py --output +# 默认 target -14 LUFS / true peak -1.5 dB / LRA 11 + +# 字幕硬烧(可选) +python3 ./scripts/burn-srt.py --output +# 默认中文字幕样式 Noto Sans CJK SC 24px,可 --font-name / --font-size / --force-style 覆盖 + +# BGM ducking(可选,需可分轨) +# 模式 1:视频自带 BGM + 外挂旁白 +python3 ./scripts/duck.py --output +# 模式 2:外挂 BGM + 外挂旁白 +python3 ./scripts/duck.py --bgm-source --output + +# 音频降噪(可选,仅素材音质差时) +python3 ./scripts/denoise.py --output +# 默认 afftdn(无外部模型依赖);要更强降噪走 arnndn:--method arnndn --rnn-model + +# 补帧(可选,仅低 fps 源材) +python3 ./scripts/interp.py --target-fps 30 --output +# 默认 minterpolate mode=blend;要更顺走 mode=mci(motion compensated,但慢且可能出鬼影) +``` + +## 干湿分离约定 + +五个都守:输出落 `_<处理名>.mp4`(如 `output_normalized.mp4`、`output_burned.mp4`、`output_ducked.mp4`、`_denoised.mp4`、`_interp.mp4`),**不覆盖输入**。多步串联时下一步以上一步产物为输入(如 ducking 后再 normalize),原产物保留作回退。 + +## 旁路条件速查 + +- `normalize.py`:无声轨 / 音频畸变 → exit 2 报错退回 exportMp4 重生;input_i 已在 ±0.3 LUFS of target → 自动跳过渲染直接拷贝 +- `burn-srt.py`:ffmpeg 不带 libass → exit 1 报错改发外挂 SRT;SRT 不存在 / 格式错 → exit 1 +- `duck.py`:AI 声画同出模式混轨没法分 → 报告用户等决策;视频无声轨且没 `--bgm-source` → exit 1 +- `denoise.py`:AI 生成视频音轨干净 → 跳过;ffmpeg 不带 afftdn/arnndn → exit 1;arnndn 没传 `--rnn-model` → exit 1 +- `interp.py`:源 fps ≥ target fps → 自动跳过拷贝;ffmpeg 不带 minterpolate → exit 1;mci 模式出鬼影 → 退 blend 模式 + +## 借鉴来源(审计用) + +- `normalize.py` ← 响度归一化(必跑步骤) +- `burn-srt.py` ← ffmpeg subtitles 滤镜烧录字幕 +- `duck.py` ← ffmpeg sidechaincompress 旁白触发 BGM 自动压低 +- `denoise.py` ← 用户素材降噪(只在用户素材用,AI 生成不用) +- `interp.py` ← ffmpeg minterpolate 补帧 diff --git a/crews/content-producer/scripts/burn-srt.py b/crews/content-producer/scripts/burn-srt.py new file mode 100644 index 00000000..f3fc0913 --- /dev/null +++ b/crews/content-producer/scripts/burn-srt.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Burn SRT subtitles into MP4 — optional, user-requested only. + +把 SRT 字幕硬烧进视频画面(不可关、跟着成片走)。跟软字幕(.srt 外挂、 +平台播放器可开关)不同——硬烧适合"平台不支持外挂字幕"或"想保证画面字 +一定显示"的场景。 + +⚠️ 可选步骤,不是必跑。Content Producer 的 AGENTS.md 工作流默认不烧字幕 +(assemble.py / exportMp4 都不烧);**仅当用户明确说"要字幕"/"烧字幕"/ +"hardcode subtitles"时才跑**。 + +落点:合成产物(output.mp4 或 output_normalized.mp4)之后、交付前。 +干湿分离:输出 `_burned.mp4`,不覆盖输入。 + +ffmpeg subtitles 滤镜要点: +- 用 `subtitles=filename='...'` 滤镜,需 libass 编译进去(Ubuntu 默认 ffmpeg 带) +- 字体:默认 libass 拿 fontconfig 找,中文字幕要 `force_style='FontName=...'` 强制 +- 字幕样式由 SRT 内 cue style 或 force_style 覆盖,本脚本默认给一套可读样式 + +Usage: + python3 ./scripts/burn-srt.py + python3 ./scripts/burn-srt.py --output + python3 ./scripts/burn-srt.py --font-name "Noto Sans CJK SC" --font-size 24 + +Exit codes: + 0 ok,字幕烧完 + 1 参数错 / ffmpeg 缺失 / 输入不存在 / ffmpeg 不带 libass + 2 ffmpeg 渲染失败(SRT 损坏 / 字体缺 / 渲染中断) +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +# 默认字幕样式——黑白配 + 半透底框,短视频通用可读样式 +DEFAULT_FONT_NAME = "Noto Sans CJK SC" # 中文兜底;libass 找不到时回退 fontconfig 默认 +DEFAULT_FONT_SIZE = 24 +DEFAULT_FORCE_STYLE = ( + "FontName={font},FontSize={size}," + "PrimaryColour=&H00FFFFFF&,OutlineColour=&H00000000&," + "BackColour=&H80000000&,BorderStyle=4," + "Outline=2,Shadow=1,Alignment=2,MarginV=40" +) + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_libass() -> None: + """ffmpeg subtitles 滤镜依赖 libass。启动时探一次,没带则报错不白跑.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + if "subtitles" not in out: + die("ffmpeg 不带 libass(subtitles 滤镜缺席),无法烧字幕;换 ffmpeg-full 完整版") + + +def validate_srt(srt_path: str) -> None: + """基本 SRT 校验:非空 + 至少一条 cue + timestamp 格式.""" + try: + content = Path(srt_path).read_text(encoding="utf-8").strip() + except OSError as e: + die(f"读 SRT 失败: {e}") + if not content: + die("SRT 文件空") + # 至少含一个 "-->" 时间戳分隔符(SRT 格式的硬标志) + if "-->" not in content: + die("SRT 不含任何 `-->` 时间戳分隔符,格式错") + + +def burn(video: str, srt: str, output: str, font_name: str, + font_size: int, force_style: str | None) -> dict: + """ffmpeg subtitles 滤镜烧 SRT. 返回渲染元数据.""" + style = ( + force_style if force_style is not None + else DEFAULT_FORCE_STYLE.format(font=font_name, size=font_size) + ) + # ffmpeg subtitles 滤镜里 filename 要单引号包裹,且整个 -vf 字串里单引号要转义 + # 用 shlex.quote 处理路径,再包单引号 + srt_escaped = shlex.quote(srt) + vf = f"subtitles=filename={srt_escaped}:force_style='{style}'" + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-vf", vf, + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "copy", # 音轨原样不动 + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg subtitles 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "srt": srt, + "output": output, + "font_name": font_name, + "font_size": font_size, + "force_style": style, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Burn SRT subtitles into MP4 (optional, user-requested only)." + ) + parser.add_argument("video", help="输入视频(合成产物,含声轨)") + parser.add_argument("srt", help="SRT 字幕文件路径") + parser.add_argument("--output", default=None, + help="输出路径,默认在输入旁加 _burned 后缀") + parser.add_argument("--font-name", default=DEFAULT_FONT_NAME, + help=f"字体名,默认 {DEFAULT_FONT_NAME}") + parser.add_argument("--font-size", type=int, default=DEFAULT_FONT_SIZE, + help=f"字体大小,默认 {DEFAULT_FONT_SIZE}") + parser.add_argument("--force-style", default=None, + help="Override libass force_style 字串(覆盖默认样式)") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + srt_path = Path(args.srt).resolve() + if not srt_path.is_file(): + die(f"SRT 文件不存在: {srt_path}") + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_burned.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + check_libass() + validate_srt(str(srt_path)) + + print(f"[info] input: {video_path}") + print(f"[info] srt: {srt_path}") + print(f"[info] output: {out_path}") + print(f"[info] font: {args.font_name} @ {args.font_size}px") + + result = burn(str(video_path), str(srt_path), str(out_path), + args.font_name, args.font_size, args.force_style) + + print(f"\n[done] burned: {out_path}", file=sys.stderr) + print(f"[info] style used: {result['force_style'][:80]}...", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/denoise.py b/crews/content-producer/scripts/denoise.py new file mode 100644 index 00000000..43e0692f --- /dev/null +++ b/crews/content-producer/scripts/denoise.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Audio noise reduction — only for poor-quality user footage. + +用 ffmpeg `afftdn`(频域降噪)或 `arnndn`(RNN 降噪)给音频去环境噪声。 +只在用户素材音质差(环境噪声大、空调嗡、键盘吱)时用——AI 生成视频的音轨 +是干净的,不需要降噪。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做降噪处理。 +**仅当用户素材音质明显差**(用户抱怨"听不清"/"有杂音"/"噪音大", +或 review.py 报噪声指标异常)时才跑。 + +arnndn vs afftdn 怎么选: +- `arnndn`(Acoustic RNN Noise Suppress Network):质量高,对人声保真, + 但要 RNN 模型文件(.rnn)。适合素材主要传人声的情况 +- `afftdn`(Audio FFT-based Noise Suppressor):纯频域降噪,无外部模型依赖, + 适合环境噪声 dominant、人声次要的情况。本脚本默认走 afftdn 避依赖 + +落点:在素材处理阶段(Step 3 用户素材预处理)跑——素材降噪后再进 assemble.py。 +不是合成产物后跑(合成后再降噪会伤及片段间衔接处的环境音一致性)。 + +干湿分离:输出 `_denoised.mp4`,不覆盖输入。 + +Usage: + python3 ./scripts/denoise.py + python3 ./scripts/denoise.py --output + python3 ./scripts/denoise.py --method arnndn --rnn-model /path/to/model.rnn + python3 ./scripts/denoise.py --noise-floor -40 --nr 12 + +Exit codes: + 0 ok,降噪完成 + 1 参数错 / ffmpeg 缺失 / 输入不存在 / arnndn 要的 RNN 模型没给 + 2 ffmpeg 渲染失败(音频损坏 / arnndn 模型加载失败) +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# 默认走 afftdn 避外部模型依赖 +DEFAULT_METHOD = "afftdn" +# afftdn 默认参数——保守不伤人声:noise floor -40dB,noise reduction 12dB(默认) +DEFAULT_NOISE_FLOOR_DB = -40.0 +DEFAULT_NR_DB = 12.0 # noise_reduction 强度,0.01-97,默认 12 已够用 +DEFAULT_NOISE_TYPE = "white" # white/vinyl/shellac/custom,多数环境噪走 white + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_method(method: str, rnn_model: str | None) -> str: + """确认 ffmpeg 带目标滤镜 + arnndn 需要的 RNN 模型.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + + if method == "afftdn": + if "afftdn" not in out: + die("ffmpeg 不带 afftdn 滤镜,换 ffmpeg-full 或改用 arnndn") + return "afftdn" + elif method == "arnndn": + if "arnndn" not in out: + die("ffmpeg 不带 arnndn 滤镜,换 ffmpeg-full 或改用 afftdn") + if not rnn_model: + die("arnndn 要 RNN 模型文件路径,传 --rnn-model ") + if not Path(rnn_model).is_file(): + die(f"RNN 模型文件不存在: {rnn_model}") + return "arnndn" + else: + die(f"unknown method: {method}; valid: afftdn, arnndn") + + +def probe_audio(video: str) -> dict | None: + """ffprobe 数音频轨,没声轨降噪没意义.""" + import json + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "a", video, + ], timeout=30) + if rc != 0: + return None + try: + return json.loads(out) + except json.JSONDecodeError: + return None + + +def denoise(video: str, output: str, method: str, + noise_floor: float, nr: float, noise_type: str, + rnn_model: str | None) -> dict: + """ffmpeg afftdn / arnndn 降噪. 返回渲染元数据.""" + if method == "afftdn": + # afftdn 真参数(ffmpeg 6+):nf=noise floor(dB),nr=noise reduction 强度, + # nt=noise type(white/vinyl/shellac/custom)。conservative 不伤人声 + af = f"afftdn=nf={noise_floor}:nr={nr}:nt={noise_type}" + else: # arnndn + af = f"arnndn=m='{rnn_model}'" + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", af, + "-c:v", "copy", # 视频轨原样不动 + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg {method} 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "output": output, + "method": method, + "noise_floor_db": noise_floor, + "nr_db": nr, + "noise_type": noise_type, + "rnn_model": rnn_model, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Audio noise reduction via ffmpeg afftdn/arnndn (optional, poor footage only)." + ) + parser.add_argument("video", help="输入视频路径(含要降噪的声轨)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _denoised 后缀") + parser.add_argument("--method", default=DEFAULT_METHOD, choices=["afftdn", "arnndn"], + help=f"降噪方法,默认 {DEFAULT_METHOD}(afftdn 频域,arnndn 需 RNN 模型)") + parser.add_argument("--noise-floor", type=float, default=DEFAULT_NOISE_FLOOR_DB, + dest="noise_floor", + help=f"afftdn noise floor dB,默认 {DEFAULT_NOISE_FLOOR_DB}") + parser.add_argument("--nr", type=float, default=DEFAULT_NR_DB, + help=f"afftdn noise reduction 强度(0.01-97),默认 {DEFAULT_NR_DB}") + parser.add_argument("--noise-type", default=DEFAULT_NOISE_TYPE, + dest="noise_type", choices=["white", "vinyl", "shellac", "custom"], + help=f"afftdn noise type,默认 {DEFAULT_NOISE_TYPE}") + parser.add_argument("--rnn-model", default=None, dest="rnn_model", + help="arnndn RNN 模型文件路径(method=arnndn 时必传)") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + audio_info = probe_audio(str(video_path)) + if not audio_info or not audio_info.get("streams"): + die("视频无声轨,降噪没意义") + + check_method(args.method, args.rnn_model) + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_denoised.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] method: {args.method}") + print(f"[info] output: {out_path}") + if args.method == "afftdn": + print(f"[info] params: noise_floor={args.noise_floor}dB nr={args.nr}dB noise_type={args.noise_type}") + + result = denoise(str(video_path), str(out_path), args.method, + args.noise_floor, args.nr, args.noise_type, args.rnn_model) + + print(f"\n[done] denoised: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/duck.py b/crews/content-producer/scripts/duck.py new file mode 100644 index 00000000..36c39501 --- /dev/null +++ b/crews/content-producer/scripts/duck.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""BGM ducking — narration/dialog drives BGM auto-ducking via sidechain. + +把 BGM 轨在旁白/对话出现时自动压低,旁白停了再放开——专业混音的标配。 +只在声画同出模式(gen.py 出的片旁白+BGM 同轨)且用户要专业混音时用。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做混音处理—— +assemble.py / normalize.py 都只碰整体响度,不动轨间电平。 +**仅当用户明确说"要混音"/"做 ducking"/"BGM 压旁白"/"professional mix"时才跑**。 + +前置:要有可分离的 BGM 轨和旁白轨。AI 声画同出模式 gen.py 出的片是 +**混轨单声道**——duck.py 没法从混轨里分离 BGM 和旁白。所以本脚本实际 +只在以下场景能用: +1. assemble.py 走 Stock Footage + TTS 模式:素材视频(含 BGM/环境音)+ + 外挂 speech.mp3 旁白——BGM 在视频轨、旁白在音频轨,可分离 +2. 用户人工提供了 BGM.mp3 和 narration.mp3 两份独立文件 +3. tts.py 生成的旁白是独立文件,BGM 也是独立文件 + +输入要两份音频 + 一份视频(或只视频,duck.py 只处音频轨再合回去)。 +落点:assemble.py 之后、normalize.py 之前——ducking 改的是轨间电平, +normalize 改的是整体响度,先 duck 再 normalize 顺序不能反。 + +ffmpeg sidechaincompress 滤镜要点: +- 把旁白轨作 sidechain input 触发 BGM 轨压缩 +- 阈值约 -25 dB(旁白起来才触),比例 8:1(压狠),起 5ms 放 300ms(自然) +- attack/release 不能太短,短了BGM抖;不能太长,长了旁白起了 BGM 没压下去 + +Usage: + python3 ./scripts/duck.py --bgm-track audio:0 + python3 ./scripts/duck.py --bgm-source bgm.mp3 --output mixed.mp4 + python3 ./scripts/duck.py --threshold -25 --ratio 8 + +Exit codes: + 0 ok,ducking 完成 + 1 参数错 / ffmpeg 缺失 / 输入不存在 + 2 ffmpeg 渲染失败(音频轨配置错 / 渲染中断) +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# Sidechain 压缩参数——专业混音通用起点,用户要调可传 override +# ⚠️ ffmpeg sidechaincompress 的 threshold / makeup 参数要归一化振幅([0,1] 范), +# 不是 dB。我们把 dB 入参转线性振幅再塞给 ffmpeg:linear = 10^(dB/20) +DEFAULT_THRESHOLD_DB = -25.0 # 旁白起来到这 dB 才触 BGM 压 +DEFAULT_RATIO = 8.0 # 8:1 压狠 +DEFAULT_ATTACK_MS = 5 # 起得快但不抖 +DEFAULT_RELEASE_MS = 300 # 放得慢,旁白停了 BGM 缓升 +DEFAULT_MAKEUP_DB = 3.0 # BGM 被压后补点 makeup 避免整体偏轻 + + +def db_to_linear(db: float) -> float: + """dB → 线性振幅(ffmpeg sidechaincompress threshold/makeup 要这套).""" + return 10 ** (db / 20.0) + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def probe_audio_streams(video: str) -> int: + """ffprobe 数音频轨数,caller 用它判断 BGM 轨存在.""" + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "a", video, + ], timeout=30) + if rc != 0: + return 0 + import json + try: + data = json.loads(out) + return len(data.get("streams", [])) + except json.JSONDecodeError: + return 0 + + +def duck(video: str, narration: str, output: str, + bgm_source: str | None, bgm_track: str, + threshold: float, ratio: float, + attack_ms: int, release_ms: int, makeup_db: float) -> dict: + """ffmpeg sidechaincompress ducking. 返回渲染元数据.""" + # BGM 来源:外挂 bgm.mp3 或视频自带音轨 + # threshold / makeup 要从 dB 转线性振幅塞给 ffmpeg sidechaincompress + threshold_lin = db_to_linear(threshold) + makeup_lin = db_to_linear(makeup_db) + # ⚠️ 滤镜图要点(ffmpeg 严要求,错一个出空片或报错): + # 1. sidechaincompress 吃两输入(main + sidechain)输出一份——BGM 给 main、旁白给 sidechain + # 2. BGM 不用 split(整个给 sidechaincompress 的 main 输入,输出一份被压过的 BGM) + # 3. 旁白要 split=2:一份作 sidechain 触发源(被 sidechaincompress 内部消费),一份最终混入 + # 4. 每个 split/asplit 输出都要被下游滤镜消费,否则报 unconnected output + if bgm_source: + # 外挂 BGM 模式:ffmpeg -i video -i narration -i bgm + # [1:a] 旁白 split:side 那份触发 BGM 压(被 sidechaincompress 消费),nar 那份最终混入 + # [2:a] BGM 整个给 sidechaincompress 的 main 输入 → 被 sidechain 压 → [mixed] + # [nar] + [mixed] amix 出最终音轨 + inputs = ["-i", video, "-i", narration, "-i", bgm_source] + af = ( + f"[1:a]asplit=2[side][nar];" + f"[2:a][side]sidechaincompress=" + f"threshold={threshold_lin}:ratio={ratio}:" + f"attack={attack_ms}:release={release_ms}:" + f"makeup={makeup_lin}[mixed];" + f"[nar][mixed]amix=inputs=2:duration=longest:dropout_transition=0[a]" + ) + mapping = ["-map", "0:v", "-map", "[a]"] + else: + # 视频自带 BGM 模式:BGM 在 [0:a],旁白作外挂 [1:a] + # [0:a] BGM 整个给 sidechaincompress 的 main 输入(不 split,省一个闲输出) + # [1:a] 旁白 split:nar_side 那份作 sidechain 触发源(被 sidechaincompress 内部消费),nar_main 那份最终混入 + # [0:a] + [nar_side] → sidechaincompress → [bgm_ducked] + # [nar_main] + [bgm_ducked] amix 出最终音轨 + inputs = ["-i", video, "-i", narration] + af = ( + f"[1:a]asplit=2[nar_side][nar_main];" + f"[0:a][nar_side]sidechaincompress=" + f"threshold={threshold_lin}:ratio={ratio}:" + f"attack={attack_ms}:release={release_ms}:" + f"makeup={makeup_lin}[bgm_ducked];" + f"[nar_main][bgm_ducked]amix=inputs=2:duration=longest:dropout_transition=0[a]" + ) + mapping = ["-map", "0:v", "-map", "[a]"] + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + *inputs, + "-filter_complex", af, + *mapping, + "-c:v", "copy", # 视频轨原样不动 + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg sidechaincompress 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "narration": narration, + "bgm_source": bgm_source or "video_internal", + "output": output, + "threshold_db": threshold, + "ratio": ratio, + "attack_ms": attack_ms, + "release_ms": release_ms, + "makeup_db": makeup_db, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="BGM ducking via sidechaincompress (optional, professional mix only)." + ) + parser.add_argument("video", help="输入视频(合成产物)") + parser.add_argument("narration", help="旁白轨独立文件(mp3/wav/opus 等)") + parser.add_argument("--bgm-source", default=None, + help="外挂 BGM 文件路径;不传则用视频自带音轨作 BGM") + parser.add_argument("--bgm-track", default="audio:0", + help="视频自带 BGM 轨,默认 audio:0(第一个音轨)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _ducked 后缀") + parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD_DB, + help=f"触发阈 dB,默认 {DEFAULT_THRESHOLD_DB}") + parser.add_argument("--ratio", type=float, default=DEFAULT_RATIO, + help=f"压缩比,默认 {DEFAULT_RATIO}") + parser.add_argument("--attack", type=int, default=DEFAULT_ATTACK_MS, + dest="attack_ms", help=f"起 ms,默认 {DEFAULT_ATTACK_MS}") + parser.add_argument("--release", type=int, default=DEFAULT_RELEASE_MS, + dest="release_ms", help=f"放 ms,默认 {DEFAULT_RELEASE_MS}") + parser.add_argument("--makeup", type=float, default=DEFAULT_MAKEUP_DB, + help=f"BGM 压后补 dB,默认 {DEFAULT_MAKEUP_DB}") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + narration_path = Path(args.narration).resolve() + if not narration_path.is_file(): + die(f"旁白文件不存在: {narration_path}") + + if args.bgm_source: + bgm_path = Path(args.bgm_source).resolve() + if not bgm_path.is_file(): + die(f"BGM 文件不存在: {bgm_path}") + bgm_src = str(bgm_path) + else: + # 没外挂 BGM → 要确认视频有音轨可作 BGM + n_audio = probe_audio_streams(str(video_path)) + if n_audio == 0: + die("视频无声轨,无法作 BGM 来源——传 --bgm-source 指定外挂 BGM 文件") + bgm_src = None + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_ducked.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] narration: {narration_path}") + print(f"[info] bgm: {bgm_src or args.bgm_track}") + print(f"[info] output: {out_path}") + print(f"[info] params: threshold={args.threshold}dB ratio={args.ratio} " + f"attack={args.attack_ms}ms release={args.release_ms}ms makeup={args.makeup}dB") + + result = duck(str(video_path), str(narration_path), str(out_path), + bgm_src, args.bgm_track, + args.threshold, args.ratio, + args.attack_ms, args.release_ms, args.makeup) + + print(f"\n[done] ducked: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/interp.py b/crews/content-producer/scripts/interp.py new file mode 100644 index 00000000..d3dc1f0f --- /dev/null +++ b/crews/content-producer/scripts/interp.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Frame interpolation — 补帧到 30/60fps,仅低 fps 源材用. + +用 ffmpeg `minterpolate` 滤镜补帧。只在低 fps 源材(如 24fps AI 生成片、 +15fps 用户素材)补到 30fps 顺滑——发布平台播放器默认 30fps 起,低于这 +画面会卡。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不动 fps。 +**仅当源 fps < target fps** 且用户要"补帧"/"顺滑"/"提升帧率"时才跑。 + +minterpolate mode 怎么选: +- `blend`(默认):纯加权混合,快、无鬼影,但运动糊——保守首选 +- `mci`(motion compensated interpolation):运动补偿,更顺但慢且 + 高运动场景易出鬼影(ffmpeg mci 算法不如商业方案稳) + +落点:合成产物(output_normalized.mp4 或上一步产物)之后、交付前。 +干湿分离:输出 `_interp.mp4`,不覆盖输入。 + +Usage: + python3 ./scripts/interp.py + python3 ./scripts/interp.py --target-fps 30 --output + python3 ./scripts/interp.py --target-fps 60 --mode mci + +Exit codes: + 0 ok,补帧完成(含源 fps ≥ target fps 自动跳过拷贝的 exit 0) + 1 参数错 / ffmpeg 缺失 / 输入不存在 / ffmpeg 不带 minterpolate + 2 ffmpeg 渲染失败(mci 出鬼影也归这档——退 blend 模式重试) +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +DEFAULT_TARGET_FPS = 30 # 发布平台播放器默认起点 +DEFAULT_MODE = "blend" # 保守首选,无鬼影 + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_minterpolate() -> None: + """ffmpeg -filters 确认带 minterpolate 滤镜.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + if "minterpolate" not in out: + die("ffmpeg 不带 minterpolate 滤镜,换 ffmpeg-full 完整版") + + +def probe_fps(video: str) -> float | None: + """ffprobe 取视频帧率(r_frame_rate),返 float 或 None.""" + import json + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "v", video, + ], timeout=30) + if rc != 0: + return None + try: + data = json.loads(out) + s = data.get("streams", [{}])[0] + rfr = s.get("r_frame_rate", "0/1") + num, den = rfr.split("/") + den_f = float(den) + if den_f == 0: + return None + return float(num) / den_f + except (json.JSONDecodeError, ValueError, IndexError): + return None + + +def interp(video: str, output: str, target_fps: int, + mode: str) -> dict: + """ffmpeg minterpolate 补帧. 返回渲染元数据.""" + # fps=p 内部先把源升到 target_fps(minterpolate 输出按 fps 滤镜设定) + # mode=blend/mci 决定补帧算法 + vf = f"minterpolate=fps={target_fps}:mi_mode={mode}" + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-vf", vf, + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "copy", # 音轨原样不动 + "-movflags", "+faststart", + "-pix_fmt", "yuv420p", + output, + ] + rc, _, err = run(cmd, timeout=1800) + if rc != 0: + die(f"ffmpeg minterpolate 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "output": output, + "target_fps": target_fps, + "mode": mode, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description=f"Frame interpolation via ffmpeg minterpolate (optional, low-fps source only)." + ) + parser.add_argument("video", help="输入视频路径") + parser.add_argument("--target-fps", type=int, default=DEFAULT_TARGET_FPS, + help=f"目标帧率,默认 {DEFAULT_TARGET_FPS}") + parser.add_argument("--mode", default=DEFAULT_MODE, choices=["blend", "mci"], + help=f"补帧模式:blend=加权混合(默认,快无鬼影但运动糊) / mci=运动补偿(更顺但慢,高运动易出鬼影)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _interp 后缀") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + check_minterpolate() + + src_fps = probe_fps(str(video_path)) + if src_fps is None: + die("ffprobe 取源帧率失败,检查视频是否损坏") + + # 源 fps ≥ target fps → 不补帧,直接拷贝避浪费 + 避不必要重压缩 + if src_fps >= args.target_fps: + print(f"[ok] src_fps={src_fps:.2f} ≥ target {args.target_fps},跳过补帧直接拷贝") + if args.output: + out_path = Path(args.output).resolve() + else: + out_path = video_path.with_name(f"{video_path.stem}_interp.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(video_path, out_path) + sys.exit(0) + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_interp.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] src_fps: {src_fps:.2f}") + print(f"[info] target_fps: {args.target_fps}") + print(f"[info] mode: {args.mode}") + print(f"[info] output: {out_path}") + + try: + result = interp(str(video_path), str(out_path), args.target_fps, args.mode) + except SystemExit as e: + # mci 模式渲染失败(出鬼影/算法崩)→ 退 blend 模式重试 + if args.mode == "mci" and e.code == 2: + print("[warn] mci 模式渲染失败,退 blend 模式重试(更稳但运动糊)", file=sys.stderr) + result = interp(str(video_path), str(out_path), args.target_fps, "blend") + else: + raise + + print(f"\n[done] interpolated: {out_path}", file=sys.stderr) + print(f"[info] {src_fps:.2f}fps → {args.target_fps}fps via {result['mode']}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/normalize.py b/crews/content-producer/scripts/normalize.py new file mode 100644 index 00000000..698f7e1e --- /dev/null +++ b/crews/content-producer/scripts/normalize.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Loudness normalization — 发布平台通用响度归一化。 + +把成片音频响度归一化到 -14 LUFS(短视频平台通用标准:抖音/视频号/B 竍竖屏通用)。 +跑在合成后、自检/交付前。这条是必跑步骤——但脚本 +本身尊重 --skip 时跳过,由 caller(AGENTS.md 工作流)决定是否强制。 + +为什么 -14 LUFS: +- 抖音/视频号/B 竍竖屏发布通用标准,与平台播放器电平匹配,避免"在我机 sound bar + 听着正"但"在手机刷到时偏轻/偏响" +- industry de-facto for short-form video + +ffmpeg 用 loudnorm 双 pass: +- Pass 1:探测当前响度 + 真实峰 + 阈值,落测量 JSON +- Pass 2:按 Pass 1 测量值应用归一化,落成片 + +干湿分离:输出落 `/output_normalized.mp4`,**不覆盖原 output.mp4**。 +caller 决定是 rename 替换还是双轨保留。 + +Usage: + python3 ./scripts/normalize.py + python3 ./scripts/normalize.py --output + python3 ./scripts/normalize.py None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def ffprobe_loudness(video: str) -> dict | None: + """Pass 1: ffmpeg loudnorm 单 pass 测量当前响度。返回测量 dict 或 None.""" + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", f"loudnorm=I={DEFAULT_TARGET_LUFS}:TP={DEFAULT_TRUE_PEAK_DB}:LRA={DEFAULT_LRA}:" + f"print_format=json", + "-f", "null", "-", + ] + rc, _, err = run(cmd, timeout=120) + if rc != 0: + return None + # loudnorm 的 JSON 落 stderr 不是 stdout + try: + # 找 stderr 里的 { ... } JSON 块 + start = err.index("{") + end = err.rindex("}") + 1 + return json.loads(err[start:end]) + except (ValueError, json.JSONDecodeError): + return None + + +def normalize(video: str, output: str, target_lufs: float, + true_peak: float, lra: float) -> dict: + """双 pass loudnorm。Pass 1 测量,Pass 2 应用.""" + m = ffprobe_loudness(video) + if not m: + die("Pass 1 测量失败——ffmpeg loudnorm 没出 JSON,输入可能无声轨或损坏", code=2) + + # 测量值传给 Pass 2 实现真归一化(不是再跑一遍单 pass) + input_i = float(m.get("input_i", 0)) + input_tp = float(m.get("input_tp", 0)) + input_lra = float(m.get("input_lra", 0)) + input_thresh = float(m.get("input_thresh", 0)) + output_i = float(m.get("output_i", DEFAULT_TARGET_LUFS)) + output_tp = float(m.get("output_tp", DEFAULT_TRUE_PEAK_DB)) + output_lra = float(m.get("output_lra", DEFAULT_LRA)) + normalization_i = float(m.get("normalization_i", 0)) + normalization_tp = float(m.get("normalization_tp", 0)) + normalization_lra = float(m.get("normalization_lra", 0)) + + # 已经达标就不重渲染(省时间、避免不必要重压缩) + if abs(input_i - target_lufs) < 0.3: + print(f"[ok] input_i={input_i:.2f} LUFS 已在 ±0.3 LUFS of target {target_lufs}," + f"跳过归一化直接拷贝") + shutil.copy2(video, output) + return {"skipped": True, "input_i": input_i, "reason": "already_at_target"} + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", ( + f"loudnorm=" + f"I={target_lufs}:TP={true_peak}:LRA={lra}:" + f"measured_I={input_i}:measured_TP={input_tp}:measured_LRA={input_lra}:" + f"measured_thresh={input_thresh}:offset={normalization_i}:" + f"linear=true:print_format=summary" + ), + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=600) + if rc != 0: + die(f"Pass 2 归一化渲染失败: {err.strip()[:500]}", code=2) + + return { + "skipped": False, + "input_i": input_i, + "input_tp": input_tp, + "input_lra": input_lra, + "output_i": output_i, + "output_tp": output_tp, + "output_lra": output_lra, + "target_i": target_lufs, + "target_tp": true_peak, + "target_lra": lra, + "normalization_i": normalization_i, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Loudness normalization to -14 LUFS (短视频平台通用标准)." + ) + parser.add_argument("video", help="输入视频路径(合成产物 output.mp4)") + parser.add_argument("--output", default=None, + help="输出路径,默认在输入旁加 _normalized 后缀") + parser.add_argument("--target-lufs", type=float, default=DEFAULT_TARGET_LUFS, + help=f"目标响度 LUFS,默认 {DEFAULT_TARGET_LUFS}") + parser.add_argument("--true-peak", type=float, default=DEFAULT_TRUE_PEAK_DB, + help=f"真实峰上限 dB,默认 {DEFAULT_TRUE_PEAK_DB}") + parser.add_argument("--lra", type=float, default=DEFAULT_LRA, + help=f"loudness range 目标,默认 {DEFAULT_LRA}") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_normalized.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] target: {args.target_lufs} LUFS / {args.true_peak} dB true peak / LRA {args.lra}") + print(f"[info] output: {out_path}") + + result = normalize(str(video_path), str(out_path), + args.target_lufs, args.true_peak, args.lra) + + print(json.dumps(result, indent=2, ensure_ascii=False)) + print(f"\n[done] normalized: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/bilibili-publish/SKILL.md b/crews/content-producer/skills/bilibili-publish/SKILL.md deleted file mode 100644 index c43ff202..00000000 --- a/crews/content-producer/skills/bilibili-publish/SKILL.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -name: bilibili-publish -description: Publish videos to Bilibili (B站) via relay proxy. One-step multipart - upload: video + metadata → relay → B站 Open Platform. No local - BILIBILI_APP_ID / BILIBILI_APP_SECRET required; uses OFB_KEY + RELAY_BASE_URL. -metadata: - openclaw: - emoji: 📺 - requires: - bins: - - python3 - env: - - RELAY_BASE_URL - - OFB_KEY - primaryEnv: OFB_KEY ---- - -# B站视频发布(bilibili-publish,relay 代理版) - -> **架构**:relay 一站式代理。 -> - skill 内**不持** BILIBILI_APP_ID / BILIBILI_APP_SECRET -> - skill 内**不做** OAuth2 授权 / token 刷新 / MD5 签名 / 分块上传 -> - 单一 multipart POST → relay → B站 Open Platform - ---- - -## 前置条件 - -1. 环境变量 `RELAY_BASE_URL`(默认 `https://relay.wiseflow.example.com`,entrypoint 注入;用户无需配置) -2. 环境变量 `OFB_KEY`(产品方发放;entrypoint 注入) -3. 视频文件准备好(mp4) - ---- - -## 使用方式 - -```bash -python3 /abs/path/to/crews/content-producer/skills/bilibili-publish/scripts/publish_bilibili.py \ - --title "视频标题" \ - --video video.mp4 \ - --tid 122 \ - --tags AI,科技,工具 -``` - -带封面和描述: - -```bash -python3 /abs/path/to/.../publish_bilibili.py \ - --title "视频标题" \ - --video video.mp4 \ - --cover cover.jpg \ - --desc "视频描述" \ - --tid 122 \ - --tags AI,科技 -``` - ---- - -## 参数说明 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--title` | 是 | 视频标题,最多 80 字 | -| `--video` | 是 | 视频文件路径(mp4) | -| `--cover` | 否 | 封面图路径(jpg/png);不提供则 B站自动截取 | -| `--desc` | 否 | 视频描述 | -| `--tid` | 否 | 分区 ID,默认 122(野生技术协会) | -| `--tags` | 是 | 逗号分隔标签,最多 10 个,每个最多 20 字 | -| `--copyright` | 否 | 1=自制(默认),2=转载 | - ---- - -## 常用分区 ID - -| tid | 分区 | -|-----|------| -| 122 | 野生技术协会 | -| 36 | 知识 · 科技 | -| 95 | 数码 | -| 207 | 资讯 | -| 21 | 日常 | -| 76 | 美食制作 | - ---- - -## Relay 调用契约 - -**Endpoint**:`POST ${RELAY_BASE_URL}/api/v1/publish/bilibili/submit` - -**Headers**: -- `X-OFB-Key: ${OFB_KEY}`(relay 鉴权) -- `Content-Type: multipart/form-data; boundary=...`(自动生成) - -**Body**(multipart/form-data): - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `title` | text | 是 | 视频标题 | -| `desc` | text | 否 | 视频描述 | -| `tid` | text | 是 | 分区 ID(数字字符串) | -| `tags` | text | 是 | 逗号分隔标签 | -| `copyright` | text | 是 | 1 或 2 | -| `video` | file | 是 | 视频文件(mp4) | -| `cover` | file | 否 | 封面图(jpg/png) | - -**Response**:JSON - -```json -{ - "ok": true, - "bvid": "BV1xxx", - "url": "https://www.bilibili.com/video/BV1xxx" -} -``` - -错误时: -```json -{"ok": false, "error": "BILI_UPLOAD_FAILED", "msg": "..."} -``` - ---- - -## Agent 工作流 - -1. 准备视频文件 + 标题 + 分区 + 标签 -2. 运行 `publish_bilibili.py`(自动完成 multipart 组装 + HTTP POST) -3. 检查 stdout JSON 输出: - - `{"ok": true, "bvid": "BVxxx", "url": "..."}` → 成功 - - `{"ok": false, "error": "..."}` → 检查 stderr 看具体错 - ---- - -## 错误处理 - -| 错误 | 原因 | 处理 | -|------|------|------| -| RELAY_BASE_URL not set | 环境变量未配置 | 检查 entrypoint 注入;手动设置 export RELAY_BASE_URL=... | -| OFB_KEY not set | 凭据未配置 | 检查 entrypoint 注入;联系产品方补发 | -| video not found | 文件路径错 | 确认 --video 路径 | -| title exceeds 80 chars | 标题过长 | 缩短 | -| HTTP 401 INVALID_OFB_KEY | OFB_KEY 无效 | 联系产品方重发 | -| HTTP 500 BILI_UPLOAD_FAILED | B站端失败 | 看 stderr 具体 msg;重试一次 | - ---- - -## 与旧版(OAuth2 + HMAC)对比 - -| 维度 | 旧(Open Platform 自接) | 新(relay 代理) | -|------|------------------------|------------------| -| 凭据位置 | 客户端 BILIBILI_APP_ID/SECRET | relay 端 | -| OAuth2 流程 | 客户端跑(code exchange / refresh) | relay 端 | -| 签名 | 客户端 HMAC-SHA256 | relay 端 | -| 分块上传 | 客户端 5MB chunk | relay 端 | -| 网络路径 | 客户端 → B站 API | 客户端 → relay → B站 API(多一跳) | -| 失败重试 | 客户端处理 | relay 端处理 | -| 退出码 | 0/1/2 | 0/1/2(保持兼容) | - -**为什么值得多一跳**: -- 凭据集中管理(避免泄露) -- 客户端代码大幅简化(移除 OAuth2 / 签名 / 分块逻辑) -- relay 端可做配额控制 / 失败重试 / 限流(业务侧) -- 客户端无需处理 token 刷新(凭据生命周期由 relay 管理) - -**代价**: -- 多一次网络跳转(latency) -- 大文件走 relay 占用 relay 带宽(成本) - -**验收标准**: -- 发一条真实动态成功 -- skill 内无 BILI 凭据(已达成:source grep 无 BILIBILI_APP_*) -- 12 单元测试全过 diff --git a/crews/content-producer/skills/bilibili-publish/bilibili-publish.sh b/crews/content-producer/skills/bilibili-publish/bilibili-publish.sh deleted file mode 100755 index 2ef9f1f5..00000000 --- a/crews/content-producer/skills/bilibili-publish/bilibili-publish.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# bilibili-publish.sh — bilibili-publish 顶层 wrapper(薄转发) -# 让 agent 用 `bilibili-publish ` 走 PATH,零路径拼接。 -# 内部转发到 scripts/publish_bilibili.py;wrapper 自身只是 exec 转发,不改语义。 -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. -while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done -SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -exec python3 "$SCRIPT_DIR/scripts/publish_bilibili.py" "$@" diff --git a/crews/content-producer/skills/bilibili-publish/scripts/publish_bilibili.py b/crews/content-producer/skills/bilibili-publish/scripts/publish_bilibili.py deleted file mode 100755 index dc052fd3..00000000 --- a/crews/content-producer/skills/bilibili-publish/scripts/publish_bilibili.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -"""Publish videos to Bilibili via relay proxy (Phase 3.1 — D1 全 proxy). - -依赖: -- 用户环境变量 `RELAY_BASE_URL`(默认 `https://relay.wiseflow.example.com`,entrypoint 注入) -- 用户环境变量 `OFB_KEY`(产品方发放) -- Python 3 stdlib(urllib / email / mimetypes) -- 无第三方依赖 -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import urllib.error -import urllib.request -import uuid -from email.generator import BytesGenerator -from email.mime.base import MIMEBase -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email import encoders -from io import BytesIO -from pathlib import Path -from typing import Optional - -# ── 常量 ───────────────────────────────────────────────────────────────────── - -DEFAULT_RELAY_BASE_URL = "https://relay.wiseflow.example.com" -RELAY_ENDPOINT = "/api/v1/publish/bilibili/submit" -REQUEST_TIMEOUT_S = 600 # 上传视频可能慢(10 分钟级) -MAX_TITLE_LEN = 80 -MAX_TAGS = 10 -MAX_TAG_LEN = 20 - - -# ── env 校验 ──────────────────────────────────────────────────────────────── - -def require_env() -> tuple[str, str]: - """校验必需的环境变量:RELAY_BASE_URL + OFB_KEY。""" - relay = os.environ.get("RELAY_BASE_URL", "").rstrip("/") - ofb_key = os.environ.get("OFB_KEY", "") - if not relay: - sys.stderr.write("[bilibili-publish] ERROR: RELAY_BASE_URL not set\n") - sys.exit(2) - if not ofb_key: - sys.stderr.write( - "[bilibili-publish] ERROR: OFB_KEY 未配置。OFB_KEY 是 VIP Club 会员凭证," - "由 ofb 掌柜签发——请向 ofb 掌柜索取该 key,交由 IT engineer 写入 " - "daemon.env 后重启实例。\n" - ) - sys.exit(2) - return relay, ofb_key - - -# ── Multipart 构建 ───────────────────────────────────────────────────────── - -def _add_text_field(mp: MIMEMultipart, name: str, value: str) -> None: - field = MIMEText(value, "plain", "utf-8") - field.add_header("Content-Disposition", "form-data", name=name) - mp.attach(field) - - -def _add_file_field(mp: MIMEMultipart, name: str, path: Path) -> None: - """添加文件字段。Content-Type 由 mimetypes 推断。""" - import mimetypes - ctype, _ = mimetypes.guess_type(str(path)) - if ctype is None: - ctype = "application/octet-stream" - maintype, subtype = ctype.split("/", 1) - with open(path, "rb") as f: - part = MIMEBase(maintype, subtype) - part.set_payload(f.read()) - encoders.encode_base64(part) - part.add_header( - "Content-Disposition", "form-data", - name=name, filename=path.name, - ) - part.add_header("Content-Type", ctype) - mp.attach(part) - - -def build_multipart( - *, - title: str, - desc: str, - tid: int, - tags: str, - copyright: int, - video_path: Path, - cover_path: Optional[Path], -) -> tuple[bytes, str]: - """构造 multipart/form-data 实体,返回 (body_bytes, content_type)。""" - mp = MIMEMultipart("form-data") - _add_text_field(mp, "title", title) - _add_text_field(mp, "desc", desc) - _add_text_field(mp, "tid", str(tid)) - _add_text_field(mp, "tags", tags) - _add_text_field(mp, "copyright", str(copyright)) - _add_file_field(mp, "video", video_path) - if cover_path is not None: - _add_file_field(mp, "cover", cover_path) - - # 序列化 - buf = BytesIO() - gen = BytesGenerator(buf, mangle_from_=False, maxheaderlen=0) - gen.flatten(mp, unixfrom=False) - body = buf.getvalue() - - # 提取 boundary 供 Content-Type 用 - # email 默认 boundary 是 `=================`;从 body 第一行抓 - boundary_line = body.split(b"\r\n", 1)[0] - boundary = boundary_line.removeprefix(b"--") - content_type = f"multipart/form-data; boundary={boundary.decode('ascii')}" - return body, content_type - - -# ── Relay HTTP 调用 ───────────────────────────────────────────────────────── - -def relay_submit( - *, - relay_url: str, - ofb_key: str, - title: str, - desc: str, - tid: int, - tags: str, - copyright: int, - video_path: Path, - cover_path: Optional[Path], - timeout: int = REQUEST_TIMEOUT_S, -) -> dict: - """multipart POST 到 relay /api/v1/publish/bilibili/submit。""" - if not video_path.is_file(): - sys.stderr.write(f"[bilibili-publish] ERROR: video not found: {video_path}\n") - sys.exit(1) - - body, content_type = build_multipart( - title=title, desc=desc, tid=tid, tags=tags, copyright=copyright, - video_path=video_path, cover_path=cover_path, - ) - url = f"{relay_url}{RELAY_ENDPOINT}" - req = urllib.request.Request( - url, - data=body, - headers={ - "X-OFB-Key": ofb_key, - "Content-Type": content_type, - }, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read()) - except urllib.error.HTTPError as e: - err_body = e.read().decode(errors="replace") - sys.stderr.write(f"[bilibili-publish] HTTP {e.code}: {err_body}\n") - try: - err = json.loads(err_body) - sys.stderr.write(f"[bilibili-publish] error code: {err.get('error', 'unknown')}\n") - except json.JSONDecodeError: - pass - sys.exit(1) - - -# ── main ───────────────────────────────────────────────────────────────────── - -def main() -> None: - parser = argparse.ArgumentParser( - description="Publish video to Bilibili via relay proxy (D1 全 proxy)" - ) - parser.add_argument("--title", required=True, help=f"Video title (max {MAX_TITLE_LEN} chars)") - parser.add_argument("--video", required=True, help="Video file path (mp4)") - parser.add_argument("--cover", help="Cover image path (jpg/png, optional)") - parser.add_argument("--desc", default="", help="Video description") - parser.add_argument("--tid", type=int, default=122, help="Partition ID (default: 122=野生技术协会)") - parser.add_argument("--tags", required=True, help="Comma-separated tags (max 10, each ≤ 20 chars)") - parser.add_argument("--copyright", type=int, default=1, choices=[1, 2], help="1=self-made, 2=repost") - args = parser.parse_args() - - # 入参校验 - if len(args.title) > MAX_TITLE_LEN: - sys.stderr.write(f"[bilibili-publish] ERROR: title exceeds {MAX_TITLE_LEN} chars\n") - sys.exit(1) - tags_list = [t.strip() for t in args.tags.split(",") if t.strip()] - if len(tags_list) > MAX_TAGS: - sys.stderr.write(f"[bilibili-publish] ERROR: more than {MAX_TAGS} tags\n") - sys.exit(1) - for t in tags_list: - if len(t) > MAX_TAG_LEN: - sys.stderr.write(f"[bilibili-publish] ERROR: tag '{t}' exceeds {MAX_TAG_LEN} chars\n") - sys.exit(1) - - video_path = Path(args.video).resolve() - cover_path = Path(args.cover).resolve() if args.cover else None - - # env - relay, ofb_key = require_env() - - # 调 relay - sys.stderr.write(f"[bilibili-publish] posting to {relay}{RELAY_ENDPOINT} ...\n") - result = relay_submit( - relay_url=relay, - ofb_key=ofb_key, - title=args.title, - desc=args.desc, - tid=args.tid, - tags=args.tags, - copyright=args.copyright, - video_path=video_path, - cover_path=cover_path, - ) - - # 输出 - sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2)) - sys.stdout.write("\n") - if not result.get("ok"): - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/bilibili-publish/scripts/tests/test_publish_bilibili.py b/crews/content-producer/skills/bilibili-publish/scripts/tests/test_publish_bilibili.py deleted file mode 100755 index 32723f13..00000000 --- a/crews/content-producer/skills/bilibili-publish/scripts/tests/test_publish_bilibili.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for publish_bilibili.py (Phase 3.1 relay proxy path). - -Covers: -- No local bilibili-publish OAuth2 / app_id / app_secret required (D1 全 proxy) -- Multipart POST to ${RELAY_BASE_URL}/api/v1/publish/bilibili/submit -- X-OFB-Key header -- File fields (video, cover) + text fields -- Response parsing -- Error handling (4xx/5xx, malformed JSON) - -All HTTP calls are mocked — these are unit tests. -""" -import base64 -import json -import re -import sys -import tempfile -import unittest -from io import BytesIO -from pathlib import Path -from unittest import mock - -SCRIPTS_DIR = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(SCRIPTS_DIR)) - -import publish_bilibili # noqa: E402 - - -def _extract_b64_part(body: bytes, field_name: str) -> bytes: - """Extract base64-decoded content of a multipart part by field name. - - email 库会把所有 part 用 base64 编码传输(MIMEText + MIMEBase)。 - 测试需要从 multipart body 抽出对应 field 的 base64 段并解码。 - """ - pattern = rb'Content-Disposition: form-data; name="' + field_name.encode() + rb'"[^\n]*\n(?:Content-Type:[^\n]*\n)?(?:MIME-Version:[^\n]*\n)?(?:Content-Transfer-Encoding:[^\n]*\n)?\n([A-Za-z0-9+/=\s]+?)\n--' - m = re.search(pattern, body, re.DOTALL) - if not m: - # 文件字段可能 Content-Type 在 Content-Disposition 之后;放宽匹配 - pattern2 = rb'name="' + field_name.encode() + rb'"[^\n]*\n(?:Content-Type:[^\n]*\n)?(?:MIME-Version:[^\n]*\n)?(?:Content-Transfer-Encoding:[^\n]*\n)?\n([A-Za-z0-9+/=\s]+?)\n--' - m = re.search(pattern2, body, re.DOTALL) - assert m, f"Part {field_name!r} not found in body" - return base64.b64decode(re.sub(rb'\s+', b'', m.group(1))) - - -class TestRelayConstants(unittest.TestCase): - def test_endpoint_is_relay_path(self): - # RELAY_ENDPOINT 是 path(不是完整 URL);完整 URL = RELAY_BASE_URL + RELAY_ENDPOINT - self.assertTrue(publish_bilibili.RELAY_ENDPOINT.startswith("/api/v1/publish/bilibili/")) - self.assertIn("submit", publish_bilibili.RELAY_ENDPOINT) - - def test_no_local_credentials(self): - # 不应再有 BILIBILI_APP_ID / BILIBILI_APP_SECRET 引用 - import inspect - src = inspect.getsource(publish_bilibili) - self.assertNotIn("BILIBILI_APP_ID", src) - self.assertNotIn("BILIBILI_APP_SECRET", src) - self.assertNotIn("OAuth", src) - self.assertNotIn("access_token", src) - self.assertNotIn("x-bili-signature", src) - self.assertNotIn("chunk", src.lower().replace(" ", "")) or True # 容忍 docstring 提"chunked" - - -class TestRequiredEnv(unittest.TestCase): - def test_relay_base_url_required(self): - with mock.patch.dict("os.environ", {}, clear=True): - with self.assertRaises(SystemExit) as ctx: - publish_bilibili.require_env() - self.assertEqual(ctx.exception.code, 2) - - def test_ofb_key_required(self): - with mock.patch.dict("os.environ", {"RELAY_BASE_URL": "https://r.example.com"}, clear=True): - with self.assertRaises(SystemExit) as ctx: - publish_bilibili.require_env() - self.assertEqual(ctx.exception.code, 2) - - def test_both_set_passes(self): - with mock.patch.dict("os.environ", { - "RELAY_BASE_URL": "https://r.example.com", - "OFB_KEY": "ofb-test-123", - }, clear=True): - relay, key = publish_bilibili.require_env() - self.assertEqual(relay, "https://r.example.com") - self.assertEqual(key, "ofb-test-123") - - -class TestMultipartBuild(unittest.TestCase): - def test_multipart_contains_video_and_text_fields(self): - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "test.mp4" - video.write_bytes(b"fake-video-content") - cover = Path(tmp) / "cover.jpg" - cover.write_bytes(b"fake-jpeg") - - body, content_type = publish_bilibili.build_multipart( - title="测试视频", - desc="描述", - tid=122, - tags="AI,科技", - copyright=1, - video_path=video, - cover_path=cover, - ) - # Content-Type 形如 multipart/form-data; boundary=... - self.assertIn("multipart/form-data", content_type) - self.assertIn("boundary=", content_type) - # Base64 解码校验字节 - self.assertEqual(_extract_b64_part(body, "video"), b"fake-video-content") - self.assertEqual(_extract_b64_part(body, "cover"), b"fake-jpeg") - # 文本字段(base64 解码后是中文字符串) - self.assertEqual(_extract_b64_part(body, "title"), "测试视频".encode("utf-8")) - self.assertEqual(_extract_b64_part(body, "desc"), "描述".encode("utf-8")) - self.assertEqual(_extract_b64_part(body, "tid"), b"122") - self.assertEqual(_extract_b64_part(body, "tags"), "AI,科技".encode("utf-8")) - self.assertEqual(_extract_b64_part(body, "copyright"), b"1") - - def test_multipart_without_cover(self): - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "test.mp4" - video.write_bytes(b"x") - body, _ = publish_bilibili.build_multipart( - title="t", desc="", tid=36, tags="x", - copyright=1, video_path=video, cover_path=None, - ) - self.assertEqual(_extract_b64_part(body, "video"), b"x") - with self.assertRaises(AssertionError): - _extract_b64_part(body, "cover") - - -class TestRelaySubmit(unittest.TestCase): - @mock.patch("publish_bilibili.urllib.request.urlopen") - def test_successful_submit(self, mock_urlopen): - mock_resp = mock.MagicMock() - mock_resp.read.return_value = json.dumps({ - "ok": True, "bvid": "BV1test", "url": "https://www.bilibili.com/video/BV1test" - }).encode("utf-8") - mock_resp.__enter__.return_value = mock_resp - mock_urlopen.return_value = mock_resp - - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"video-bytes") - - result = publish_bilibili.relay_submit( - relay_url="https://r.example.com", - ofb_key="ofb-test", - title="t", - desc="", - tid=122, - tags="AI,tech", - copyright=1, - video_path=video, - cover_path=None, - ) - self.assertTrue(result["ok"]) - self.assertEqual(result["bvid"], "BV1test") - args, _ = mock_urlopen.call_args - req = args[0] - self.assertEqual(req.full_url, "https://r.example.com/api/v1/publish/bilibili/submit") - # header 校验:检查原始 headers dict(不依赖 urllib 归一化) - header_names_lower = {k.lower(): v for k, v in req.headers.items()} - self.assertEqual(header_names_lower.get("x-ofb-key"), "ofb-test") - ctype = header_names_lower.get("content-type", "") - self.assertIn("multipart/form-data", ctype) - - @mock.patch("publish_bilibili.urllib.request.urlopen") - def test_relay_4xx_propagates_error(self, mock_urlopen): - import urllib.error - err = urllib.error.HTTPError( - "https://r.example.com/api/v1/publish/bilibili/submit", - 401, "Unauthorized", {}, - BytesIO(b'{"error":"INVALID_OFB_KEY"}'), - ) - mock_urlopen.side_effect = err - - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"x") - with self.assertRaises(SystemExit) as ctx: - publish_bilibili.relay_submit( - relay_url="https://r.example.com", - ofb_key="bad", - title="t", desc="", tid=122, tags="x", copyright=1, - video_path=video, cover_path=None, - ) - self.assertEqual(ctx.exception.code, 1) - - @mock.patch("publish_bilibili.urllib.request.urlopen") - def test_relay_5xx_propagates_error(self, mock_urlopen): - import urllib.error - err = urllib.error.HTTPError( - "https://r.example.com/api/v1/publish/bilibili/submit", - 500, "Internal Server Error", {}, - BytesIO(b'{"error":"BILI_UPLOAD_FAILED"}'), - ) - mock_urlopen.side_effect = err - - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"x") - with self.assertRaises(SystemExit): - publish_bilibili.relay_submit( - relay_url="https://r.example.com", - ofb_key="k", - title="t", desc="", tid=122, tags="x", copyright=1, - video_path=video, cover_path=None, - ) - - -class TestMainCli(unittest.TestCase): - def test_missing_video_exits_1(self): - with mock.patch.dict("os.environ", { - "RELAY_BASE_URL": "https://r.example.com", "OFB_KEY": "k", - }, clear=True): - with self.assertRaises(SystemExit) as ctx: - with mock.patch("sys.argv", ["publish_bilibili", "--title", "t", - "--video", "/nonexistent.mp4", - "--tags", "x"]): - publish_bilibili.main() - self.assertEqual(ctx.exception.code, 1) - - def test_title_too_long_exits_1(self): - with tempfile.TemporaryDirectory() as tmp: - video = Path(tmp) / "v.mp4" - video.write_bytes(b"x") - with mock.patch.dict("os.environ", { - "RELAY_BASE_URL": "https://r.example.com", "OFB_KEY": "k", - }, clear=True): - with self.assertRaises(SystemExit) as ctx: - with mock.patch("sys.argv", ["publish_bilibili", "--title", "x" * 100, - "--video", str(video), - "--tags", "x"]): - publish_bilibili.main() - self.assertEqual(ctx.exception.code, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/crews/content-producer/skills/collage-broll/SKILL.md b/crews/content-producer/skills/collage-broll/SKILL.md new file mode 100644 index 00000000..2b103648 --- /dev/null +++ b/crews/content-producer/skills/collage-broll/SKILL.md @@ -0,0 +1,389 @@ +--- +name: collage-broll +description: 将约 5 秒口播文稿、观点句或抽象概念做成高级 editorial halftone paper-collage / 半调纸拼贴 B-roll。用户说"collage b-roll""纸拼贴 b-roll""半调拼贴""拼贴风格配画面""用这段文稿做拼贴动画",或希望把一句文稿转成拼贴视觉隐喻时,必须使用此 skill。强制采用三阶段审批:先只提视觉隐喻,用户确认后用 siliconflow-img-gen 生成彩色拼贴静帧,静帧再次确认后用公共 aigc-video-gen 技能(i2v 首尾帧插值)组装动画。视频生成走百炼 happyhorse-1.1-i2v 候选链(沿链 fallback)或火山 Seedance(视 env 配置)。 +metadata: + openclaw: + emoji: 🗞️ + requires: + bins: + - python3 + - ffmpeg + - ffprobe + env: + - AWK_API_KEY + primaryEnv: AWK_API_KEY + homepage: https://www.volcengine.com/docs/82379/1541523 +--- + +# Collage B-roll(纸拼贴组装动画) + +把一句约 5 秒的口播压成一个 sharp visual idea,再做成高级编辑风纸拼贴组装动画。 + +默认链路: + +1. 只设计视觉隐喻,等待用户确认(Gate 1) +2. 只生成最终静帧,等待用户确认(Gate 2) +3. 自动调 `aigc-video-gen` 生成视频并完成 QA(Gate 3) + +这两个确认闸门是工作流的一部分。它们让用户把注意力放在审美和方向上,同时避免错误隐喻或错误静帧直接消耗视频生成成本。 + +## 强制审批协议 + +### Gate 1:隐喻确认 + +收到文稿后,先提视觉隐喻,不生成图片、不生成视频、不调用任何视频模型。 + +向用户交付每条的: + +- 核心意思 +- 情绪 +- 一句话视觉命题 +- 3–6 个关键物件 +- 建议底色与局部点色 +- 预期组装顺序 + +然后明确停下,等待用户回复"可以""通过""全部通过"或给出逐条修改意见。 + +如果用户只确认部分编号,只让通过的条目进入 Gate 2;未通过条目继续修改隐喻。 + +### Gate 2:静帧确认 + +隐喻确认后,才写 visual spec 和 imagegen prompt,并用 `siliconflow-img-gen` 技能生成最终静帧。 + +把原图保存到项目目录,生成带编号的静帧 contact sheet,向用户展示并再次停下。此阶段仍然不调 `aigc-video-gen`,也不生成视频。 + +如果用户只确认部分静帧,只让通过的条目进入 Gate 3;需要修改的静帧先重生并重新确认。 + +### Gate 3:视频生成 + +静帧确认后,不再询问使用哪个视频模型,直接调公共 `aigc-video-gen` 技能走 i2v 首尾帧插值——默认走百炼 `happyhorse-1.1-i2v`(沿链 fallback 到 1.0 → wan2.7),百炼没配走火山 Seedance Fast → Normal → Mini。只有用户明确指定其他模型时才 `aigc-video-gen --model ` 覆盖。 + +## 成功标准 + +- 一句话只表达一个清晰隐喻 +- 同一批画面有统一设计语言,但不强制全部蓝底 +- 背景是强烈、平坦、均匀的色场,可按语意变化 +- 主体以黑白 halftone photographic cut-outs 为骨架 +- 关键卡片、按钮、胶片、规则册等允许使用红、黄、青、橙、紫、奶油白等彩色纸张 +- 所有纸片有清晰裁切边、奶油白 keyline、低透明度柔和阴影和纸张颗粒 +- 动作是 assemble-from-empty,而不是轻微漂移、晃动或慢 zoom +- 无字幕、无口播全文、无 logo、无水印、无 UI +- 默认交付 9:16、5 秒、720×1280、有声画同出(`aigc-video-gen` 默认 `audio: true`,旁白/BGM/环境音写在 prompt 里)MP4 + +## 什么时候不要用 + +- 需要精确控制图层、遮挡、镜头穿越或可编辑时间线:改用分层动画工具 +- 只需要视频提示词,不需要生成成片:直接写 prompt 即可,不用走本流程 +- 需要真实人物产品广告或口播演员:不要走本拼贴流程 +- 用户明确要可逐层修改的透明素材:本 skill 默认不拆透明图层 + +## 默认项目目录 + +路径契约——落在 `output_videos/` 下,名 ``: + +```text +output_videos// +├── brief.md # 文稿 + Gate 1 隐喻清单 +├── visual-spec.json # Gate 2 视觉规格 +├── imagegen-prompts.md # Gate 2 Seedream prompt 留档 +├── gen-jobs.json # Gate 3 aigc-video-gen 批量调用清单 +├── gate2-qa.md # 静帧 QA 结论 +├── gate3-qa.md # 视频 QA 结论 +├── still-contact-sheet.jpg # Gate 2 静帧总图 +├── video-contact-sheet-all.jpg # Gate 3 全部成片逐秒抽帧 +├── 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-/... +``` + +## Phase 1:设计视觉隐喻 + +先把文稿压成一个视觉命题。 + +提取: + +- 核心意思:观众最终要看懂什么 +- 情绪:冷静、惊讶、紧迫、豁然开朗、荒诞、反讽 +- 动作动词:打开、连接、漏掉、装订、归档、点亮、压缩、分叉、组装 +- 可视化隐喻:机器、时钟、胶片、档案柜、控制台、规则册、漏斗、轨道、棋子 + +不要把文稿逐字放进画面。默认一条文稿只做一个隐喻,控制在 3–6 个关键物件;元素过多会让语意变弱,也会让 i2v 组装不稳定。 + +批量隐喻优先形成前后叙事:例如先表现手工消耗与经验流失,再表现规范沉淀与人机分工。 + +### Gate 1 输出示例 + +```text +1. 核心意思:经验每次都在重复消耗 + 视觉隐喻:熟练剪辑师围着巨大的胶片时钟逐帧裁切,时钟走完一圈却只得到一小段成片 + 关键物件:胶片时钟、剪辑师、剪刀、短胶片 + 色彩:焦橙底,奶油白与浅青点色 + 组装顺序:时钟 → 人物与剪刀 → 胶片 → 最终短输出 +``` + +输出后停下等待确认。 + +## Phase 2:生成彩色拼贴静帧 + +隐喻确认后,先写自包含的 `visual-spec.json`,再写 imagegen prompt。 + +### Visual spec + +```json +{ + "script_meaning": "", + "visual_metaphor": "", + "style_signature": "flat bold color field, mixed black-and-white halftone cut-outs and colored cardstock accents, crisp cut edges, cream keylines, soft paper shadows, editorial paper collage", + "aspect_ratio": "9:16", + "color_field": { + "background_hex": "", + "accent_colors": [], + "paper_grain": "fine uncoated-paper fiber" + }, + "elements": [ + { + "what": "", + "role": "", + "motion": "", + "placement": "" + } + ], + "composition": { + "layout": "", + "negative_space": "", + "final_frame": "" + }, + "motion_plan": "structure first, subject or cards second, action and result last", + "avoid": "typography, readable letters, numerals, logos, watermark, UI, subtitles, glossy 3D, photoreal environment" +} +``` + +### 色彩规则 + +不要把 cobalt blue 当成唯一默认值。根据语意挑选强色场,并在一批作品中保持"同设计语言、不同底色": + +- 焦橙 / 红:时间消耗、劳动、紧迫 +- 芥末黄:工具、警示、经验漏失 +- 墨绿:认知、审美、系统重置 +- 深紫:规范、沉淀、长期记忆 +- 青绿:判断、协作、自动执行 + +主体可以黑白半调为主,但局部彩色纸张必须服务信息层级,不要为了彩色而彩色。 + +### Imagegen prompt 模板(siliconflow-img-gen / Seedream) + +用 `siliconflow-img-gen` 技能(Seedream doubao-seedream-4.5,fallback doubao-seedream-5.0-lite): + +```bash +siliconflow-img-gen --prompt "<下面整段>" --image-size 1600x2848 --out-dir /01-/frames/ +``` + +Prompt 模板(英文,Seedream 对英文 prompt 响应更好): + +```text +Use case: ads-marketing +Asset type: final still frame for a 9:16 image-to-video B-roll clip +Primary request: Create a finished editorial paper-collage image expressing [一句话视觉命题]. +Scene/backdrop: perfectly flat [颜色] paper field [hex] with subtle uncoated paper fiber. +Style/medium: premium editorial stop-motion paper collage; black-and-white halftone photographic cut-outs mixed with selective [点色] colored cardstock. +Composition/framing: vertical 9:16 locked poster frame; central subject within the middle 70 percent; generous clean color-field negative space; 3–6 large separable paper groups for later assemble-from-empty animation. +Materials/textures: visible printed halftone dots, crisp machine-cut edges, thin warm-cream paper keylines, soft low-opacity physical drop shadows. +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` 范围**在 prompt 里复用,不靠参考图。 + +### 静帧 QA + +- 隐喻是否一眼看懂 +- 主体是否集中 +- 是否有假字、logo、水印或 UI +- 是否保留足够纯色场,方便从空场组装 +- 是否是 3–6 个清晰大组,而不是满屏碎片 +- 同一批是否统一质感但有色彩变化 + +将通过 QA 的原图复制到 `/frames/still.png`,生成带编号的静帧 contact sheet,展示给用户并停下等待 Gate 2 确认。静帧 QA 结论写入 `/gate2-qa.md`。 + +如果用户要求重生部分静帧,重生后生成 `still-contact-sheet-v2.jpg`(后续轮次递增 v3、v4…),保留旧版 contact sheet 不覆盖,方便对比。 + +拼静帧 contact sheet 用 ffmpeg tile: + +```bash +ffmpeg -y -pattern_type glob -i "/*/frames/still.png" \ + -vf "scale=270:480,tile=5x1" \ + -frames:v 1 /still-contact-sheet.jpg +``` + +段数 > 5 时分多行(`tile=5x2`、`5x3`…)。 + +## Phase 3:用 aigc-video-gen i2v 生成视频 + +### 1. 准备首尾帧 + +保留 imagegen 原图 `still.png`,再统一尾帧到 720x1280(`aigc-video-gen` i2v 收 720P/1080P,默认 720P): + +```bash +ffmpeg -y -i /frames/still.png \ + -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \ + /frames/last-frame.png +``` + +首帧默认是与尾帧相同底色的纯色空纸面(assemble-from-empty 的核心——从空场开始组装): + +```bash +ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 \ + -frames:v 1 /frames/first-frame.png +``` + +如果用户明确要求不从完全空白开始,首帧才保留一个基础物件。 + +### 2. 写 aigc-video-gen 动画 prompt + +动作顺序默认采用: + +```text +基础结构 → 人物或关键卡片 → 连接件 → 动作 → 最终结果 +``` + +`aigc-video-gen` 的 `--prompt` 是**声画同出**描述(中文,happyhorse / Seedance 对中文响应好)。组装顺序与约束中文化写进 prompt: + +- 组装顺序段:"画面从纯色空场开始,依次滑入 [基础结构] → [人物/卡片] → [连接件] → [动作],最终定格在已确认的完成构图" +- 机位与镜头约束:"固定机位,无切镜、无 zoom、无变形" +- 画面禁字:"画面无文字、无 logo、无水印、无 UI" +- 声画同出补充(`aigc-video-gen` 默认有声):"音频:纸片滑入的嗒嗰声 + 卡位时的咔嗒声 + 最终定格的短促 BGM 收尾" + +prompt 模板: + +```text +画面从纯色空场开始,依次滑入 [基础结构] → [人物/卡片] → [连接件] → [动作],最终定格在已确认的完成构图。固定机位,无切镜、无 zoom、无变形。画面无文字、无 logo、无水印、无 UI。音频:纸片滑入的嗒嗰声 + 卡位时的咔嗒声 + 最终定格的短促 BGM 收尾。 +``` + +每条 prompt 都要明确 `--image first-frame.png` 是空首帧、`--last-frame last-frame.png` 是确认过的完成帧。最终构图必须贴近 last-frame,不让模型自由改造尾帧。 + +### 3. 批量调用 aigc-video-gen + +创建 `gen-jobs.json`。每个 job 用首尾帧插值(i2v 模式): + +```json +{ + "prompt": "", + "first_frame": "/frames/first-frame.png", + "last_frame": "/frames/last-frame.png", + "output": "/gen-runs/run-v01/final-5s.mp4", + "ratio": "9:16", + "resolution": "720P", + "duration": 5 +} +``` + +逐条调公共 `aigc-video-gen` 走 i2v 模式(首尾帧插值): + +```bash +aigc-video-gen --mode i2v \ + --image /frames/first-frame.png \ + --last-frame /frames/last-frame.png \ + --prompt "" \ + --output /gen-runs/run-v01/final-5s.mp4 \ + --ratio 9:16 --resolution 720P --duration 5 +``` + +`aigc-video-gen` 内部已带候选链 fallback(百炼 happyhorse-1.1-i2v 沿链 1.1 → 1.0 → wan2.7,百炼没配走火山 Seedance Fast → Normal → Mini)+ decisions.log 落盘,agent 只需逐条调度。 + +如果出现 i2v 不收首尾帧的报错(`aigc-video-gen` 退出码非 0),检查 first-frame.png / last-frame.png 是否真存在、是否 720x1280——`aigc-video-gen` 要求相对路径在 `output_videos/` 下,**调用时 workdir 必须是 workspace 根**。 + +### 4. 强制无声交付 + +拼贴动画默认无声交付,但 `aigc-video-gen` 声画同出模式会出声。Gate 3 出片后用 ffmpeg 抽无声版交付: + +```bash +ffmpeg -y -i /final-5s.mp4 \ + -map 0:v:0 -c:v copy -an \ + /final-5s-noaudio.mp4 +``` + +默认交付 `final-5s-noaudio.mp4`,保留原始 `final-5s.mp4` 作为中间产物。 + +如果用户明确要"带声"——拼贴动画的纸片嗰声 + BGM 是 `aigc-video-gen` 声画同出出的,可能挺贴——就不抽无声,直接交付 `final-5s.mp4`。但默认走无声。 + +## 视频 QA + +不要只看尾帧,必须检查组装过程和最终落位。 + +### Contact sheet + +```bash +ffmpeg -y -i /final-5s-noaudio.mp4 \ + -vf "fps=1,scale=270:480,tile=5x1" \ + -frames:v 1 /contact-sheet.jpg +``` + +通过标准: + +- 首帧接近纯色空场;边缘轻微提前露出纸片可以接受 +- 中段能看到结构、人物或卡片逐步进入,而不是整体淡入 +- 没有切镜、zoom、3D 化或写实场景漂移 +- 没有假字、logo、水印或 UI +- 最终帧与确认静帧一致;轻微姿态或细节漂移(如人物姿势微变、小零件增减)只要不影响隐喻语义即可判通过,不要为此重跑 +- 成片为 720×1280、有声画同出(`final-5s.mp4`)或无声(`final-5s-noaudio.mp4`)、5 秒 + +另外抽取视频末帧,与确认静帧并排生成 `end-frame-comparison.jpg`。批量项目再合并三张总览图: + +- `video-contact-sheet-all.jpg`:全部成片逐秒抽帧 +- `video-first-frame-all.jpg`:全部成片实际首帧,验证真的从空色场开始 +- `end-frame-comparison-all.jpg`:确认静帧与视频末帧并排对照 + +逐条 QA 结论(含带瑕疵通过的判定理由)写入 `/gate3-qa.md`。 + +### 成片技术自检(强制闸门) + +视觉 QA(contact sheet 看组装过程与落位)完成后,**必须**再跑公共 `video-review` 技术自检闸门,verdict=pass 才进交付: + +```bash +video-review /final-5s-noaudio.mp4 +# 或带声版:video-review /final-5s.mp4 +``` + +`video-review` 查的是技术层硬伤(ffprobe 全字段 / 5 位抽帧黑帧扫 / 音频电平 / 时长分辨率一致性),与上面的视觉 QA(看隐喻是否一眼看懂、组装过程是否成立)**互补不重叠**——视觉 QA 评审美与语义,video-review 评技术合规。verdict=fail 按 critical 项修或重生对应 job,verdict=warn 向用户复述由其决定。详见 `video-review` 技能 SKILL.md。 + +> 拼贴动画默认无声交付时,`audio_absent` warning 是预期(`final-5s-noaudio.mp4` 本就是抽音轨版),warn 可放行;带声版 `final-5s.mp4` 出 `audio_absent` 则 critical——`aigc-video-gen` 声画同出模式该出声没出声是硬伤,退回重生成。 + +### 常见问题 + +- 首帧边缘提前露出:轻微可接受;严格空场需求改用更坚定的 first-frame(纯色 + 边缘 padding) +- 组装感弱:缩短元素数量,并把 prompt 改为明确的逐件"滑入 / 卡位"顺序 +- 尾帧漂移:强化 prompt 里"最终定格在已确认的完成构图",`aigc-video-gen` i2v 的 last-frame 权重高 +- 出现假字:先回到静帧重生(Seedream 也可能出假字),不要直接用视频 prompt 修补 +- 个别视频失败:只重跑对应 job,不要重跑已经通过的条目 +- i2v 报错(`aigc-video-gen` 退出码非 0):检查首尾帧是否 720x1280、是否真存在、workdir 是否 workspace 根 + +## 默认交付 + +向用户交付: + +- 每条 `/gen-runs/run-v01/final-5s-noaudio.mp4`(或 `final-5s.mp4` 若用户要带声) +- 每条 contact sheet +- 批量总 contact sheet +- 最终帧对照图 +- 一句说明每条文稿如何转成视觉隐喻 + +如果成片问题来自 `aigc-video-gen` i2v 的生成限制(组装感弱 / 尾帧漂移),直接说明;只有需要精确图层控制时,才建议切换到其他方案(如 Manim 科学动画 → manim-explainer)。 + +## 脚本清单 + +| 脚本 | 文件名 | 用途 | +|------|--------|------| +| Gate 3 批量调度 | `scripts/run_gate3.py` | 读 gen-jobs.json,逐条调公共 `aigc-video-gen` i2v 模式(首尾帧插值),落产物 + decisions.log | + +visual-spec.json 生成、imagegen prompt 拼装、contact sheet 拼图、首尾帧 ffmpeg 处理——这些靠 agent 直接调 `siliconflow-img-gen` + ffmpeg 完成,不单独上脚本(agent 直接调更灵活,且避免脚本重复造轮子)。 diff --git a/crews/content-producer/skills/collage-broll/scripts/check_setup.sh b/crews/content-producer/skills/collage-broll/scripts/check_setup.sh new file mode 100644 index 00000000..aed734ee --- /dev/null +++ b/crews/content-producer/skills/collage-broll/scripts/check_setup.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# 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 视频) +# 不探 venv——仓根 requirements.txt 统一装,不留独立 venv(xiaobei 语境) + +set -u + +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) +if [ -n "${AWK_API_KEY:-}" ]; then + ok "AWK_API_KEY 已设置(Gate 2 静帧可用)" +else + bad "AWK_API_KEY 未设置(Gate 2 静帧生成要——到 https://console.volcengine.com/ark 创建后 export 到 shell 配置)" +fi + +# 2. 视频平台 key(Gate 3 视频生成要——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)" +elif [ -n "${AWK_GEN_KEY:-}" ]; then + ok "AWK_GEN_KEY 已设置(Gate 3 走火山 Seedance,百炼未配)" +else + bad "视频平台 key 都未设置(Gate 3 要 MODELSTUDIO_API_KEY 百炼 或 AWK_GEN_KEY 火山)" +fi + +# 3. ffmpeg / ffprobe +if command -v ffmpeg >/dev/null 2>&1; then + ok "ffmpeg 已装" +else + bad "ffmpeg 缺失(macOS: brew install ffmpeg; Debian/Ubuntu: sudo apt install ffmpeg)" +fi +if command -v ffprobe >/dev/null 2>&1; then + ok "ffprobe 已装" +else + bad "ffprobe 缺失(跟 ffmpeg 同包,装 ffmpeg 即带)" +fi + +# 4. Python >= 3.10 +if command -v python3 >/dev/null 2>&1; then + PY_VER=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo "0.0") + PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1) + PY_MINOR=$(echo "$PY_VER" | cut -d. -f2) + if [ "$PY_MAJOR" -gt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -ge 10 ]; }; then + ok "Python $PY_VER(>= 3.10)" + else + bad "Python $PY_VER 过旧(需要 >= 3.10;macOS: brew install python3;或从 python.org 安装)" + fi +else + bad "python3 缺失(macOS: brew install python3;或从 python.org 安装)" +fi + +exit $FAIL diff --git a/crews/content-producer/skills/collage-broll/scripts/run_gate3.py b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py new file mode 100644 index 00000000..4dc07255 --- /dev/null +++ b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Gate 3 批量调度——读 gen-jobs.json 逐条调公共 aigc-video-gen wrapper 走 i2v 模式(首尾帧插值)。 + +每个 job 字段: + prompt aigc-video-gen --prompt(中文声画同出描述) + first_frame 首帧路径(纯色空场,720x1280) + last_frame 尾帧路径(确认静帧裁到 720x1280,720P) + output 输出 MP4 路径(相对 output_videos/,aigc-video-gen 的 ensure_safe_output 要求) + ratio 默认 9:16 + resolution 默认 720P + duration 默认 5 + +aigc-video-gen wrapper 内部已带候选链 fallback + decisions.log 落盘,本脚本只做批量调度—— +串行调(视频生成是异步轮询任务,并行调会撞平台并发限)。 + +Usage: + python3 /scripts/run_gate3.py --batch /gen-jobs.json + python3 /scripts/run_gate3.py --batch /gen-jobs.json --dry-run + +Exit codes: + 0 全部 job 跑通 + 1 参数错 / gen-jobs.json 不存在 / 格式错 / aigc-video-gen wrapper 不在 PATH + 2 部分 job 失败(stderr 报失败清单,已跑通的保留) +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +# 公共 wrapper——aigc-video-gen(PATH 化调用,不裸引用其下 scripts/gen.py) +WRAPPER = "aigc-video-gen" + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run_one(job: dict, job_id: int, dry_run: bool) -> tuple[bool, str]: + """调 aigc-video-gen wrapper i2v 跑一个 job. 返 (ok, detail).""" + for required in ("prompt", "first_frame", "last_frame", "output"): + if not job.get(required): + return False, f"job {job_id} missing field: {required}" + + cmd = [ + WRAPPER, + "--prompt", job["prompt"], + "--image", job["first_frame"], # i2v 首帧 + "--last-frame", job["last_frame"], # i2v 尾帧 + "--ratio", job.get("ratio", "9:16"), + "--resolution", job.get("resolution", "720P"), + "--duration", str(job.get("duration", 5)), + "--output", job["output"], + ] + + if dry_run: + print(f"[dry-run] job {job_id}: {' '.join(cmd[:4])} ... --output {job['output']}") + return True, "dry-run skipped" + + print(f"[info] job {job_id}: aigc-video-gen i2v → {job['output']}") + try: + r = subprocess.run(cmd, timeout=1200) + if r.returncode == 0: + return True, f"ok exit 0 → {job['output']}" + return False, f"aigc-video-gen exit {r.returncode} for job {job_id}(查 wrapper stderr + decisions.log)" + except subprocess.TimeoutExpired: + return False, f"aigc-video-gen timeout 1200s for job {job_id}" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Gate 3 批量调度——读 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="只打印不真调") + args = parser.parse_args() + + if not shutil.which(WRAPPER): + die(f"wrapper 不在 PATH: {WRAPPER}(确认公共 aigc-video-gen 已通过 apply-addons.sh 软链到 ~/.openclaw/bin)") + + batch_path = Path(args.batch).resolve() + if not batch_path.is_file(): + die(f"gen-jobs.json 不存在: {batch_path}") + + try: + jobs = json.loads(batch_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"gen-jobs.json 格式错: {e}") + + if not isinstance(jobs, list): + die("gen-jobs.json 顶层数组不是 list") + + print(f"[info] batch: {batch_path} ({len(jobs)} jobs)") + + failures: list[tuple[int, str]] = [] + for i, job in enumerate(jobs): + ok, detail = run_one(job, i, args.dry_run) + print(detail) + if not ok: + failures.append((i, detail)) + + if failures: + print(f"\n[fail] {len(failures)} job(s) failed:", file=sys.stderr) + for jid, det in failures: + print(f" job {jid}: {det}", file=sys.stderr) + sys.exit(2) + + print(f"\n[ok] all {len(jobs)} jobs completed") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/design-full/SKILL.md b/crews/content-producer/skills/design-full/SKILL.md new file mode 100644 index 00000000..133f0a47 --- /dev/null +++ b/crews/content-producer/skills/design-full/SKILL.md @@ -0,0 +1,262 @@ +--- +name: design-full +description: 平面设计全案——完整网页/落地页、APP/产品界面、品牌视觉体系。从需求 brief 到设计系统选取、素材获取、HTML/CSS 编写、视觉 review、交付归档的完整工作流。接到"做网页/落地页/APP 界面/品牌视觉"类平面设计需求时走本技能。 +metadata: + openclaw: + emoji: 🎨 + requires: + bins: + - python3 +--- + +# 平面设计全案(design-full) + +## 适用场景 + +用户要做以下任一平面设计工作: + +- 完整网页 / 落地页 / 团队介绍页 / 404 页等 +- APP / 产品界面 / 管理后台 / SaaS 面板原型 +- 品牌视觉体系(色彩 / 字体 / 组件 / 间距规范) + +不适用:视频制作(→ `video-producer` / `collage-broll` / `manim-explainer`)。 + +--- + +## 工作流 + +### Step 1:建工作区(强制起点) + +每项设计任务开始前**必须**先建独立文件夹,所有产出归档其中: + +```bash +design-full init <任务名> +``` + +产出目录结构(落在工作区根的 `design_assets/` 下): + +``` +design_assets/YYYY-MM-DD-<任务名>/ +├── brief.md # 设计需求模板(待填写,确认后不可跳过) +├── prompts.json # 生图参数记录 +├── source/ # 原始素材(参考图、品牌资产等) +└── output/ # 成品输出(HTML/CSS 文件、组件预览页) +``` + +`design_assets/` 同时建 `references/` 与 `brand/` 两个共享子目录(跨任务复用参考素材与品牌资产)。 + +### Step 2:Brief 确认(强制闸门) + +把需求整理写入 `brief.md`,**发给用户确认,等待明确同意**。确认前不得进入后续步骤。后续视觉 review 以 brief 为基准对照。 + +brief 至少含:产品类型 / 页面或界面清单 / 功能范围 / 风格方向 / 品牌约束 / 参考素材。 + +### Step 3:设计系统选取 + +每项任务在 brief 确认后、进具体设计前**必须**调本技能确定设计系统: + +```bash +design-full pick "<风格描述>" +``` + +按风格描述从内置 14 套设计系统库匹配最合适的 1–3 套,展示匹配结果及推荐理由给用户,**等待确认选定**。用户也可指定参考品牌或自定义风格,本技能据此生成定制 DESIGN.md。 + +选定后把该设计系统规范写入任务 `DESIGN.md`,后续所有 HTML/CSS 产出的色彩、字体、间距、组件样式都遵循该规范。 + +### Step 4:素材获取 + +页面所需配图 / 背景图 / 参考图: + +- **优先**:公共 `pexels-footage` / `pixabay-footage` 搜索下载 +- **备选**:公共 `siliconflow-img-gen` 生成 +- 下载或生成的素材保存到 `source/` 目录 + +### Step 5:HTML + CSS 编写 + +- CSS custom properties 定义设计 token(颜色、间距、字号、阴影)——严格遵循 DESIGN.md +- 语义化标签(header / main / section / footer) +- 响应式(min-width: 768px / 1024px 断点) +- hover / focus / active 状态完备 +- 图片引用 `source/` 中的素材 + +### Step 6:视觉 Review(强制闸门) + +生成页面 / 组件后**必须**调视觉模型 review,不得跳过: + +1. 用 `image` 工具查看生成结果 +2. 对照 `brief.md` 和 `DESIGN.md` 逐项检查:风格一致性、组件规范遵循度、响应式表现、交互状态完整性 +3. 发现偏差 → 调整 CSS token 或 HTML 结构后重新输出(**最多 3 轮**) +4. Review 通过 → 发送给用户 + +### Step 7:交付归档 + +最终确认后把文件保存到任务文件夹 `output/` 目录,归档并更新 `index.md`。 + +--- + +## 三条子工作流(按任务类型择) + +按 brief 里的产品类型择一条子工作流执行。Step 1/2/3/6/7 是三条共用骨架,下面只列各子工作流的 Step 4/5 差异。 + +### 工作流 A:完整网页 / 落地页设计 + +``` +Step 2 brief 含: + - 页面类型(产品介绍页/活动落地页/团队介绍/404 页...) + - 页面清单与信息架构(Sections 列表) + - 交互功能范围(纯静态展示/含表单/含轮播...) + - 风格参考(可提供品牌名或描述词) + - 是否需要深色模式 + - 品牌约束(品牌色、字体、LOGO — 从 MEMORY.md 获取) +Step 4 素材:页面所需配图/背景图 → pexels-footage / pixabay-footage 优先,siliconflow-img-gen 备选 +Step 5 编写: + - CSS custom properties 定义设计 token —— 严格遵循 DESIGN.md + - 语义化标签(header / main / section / footer) + - 响应式(min-width: 768px / 1024px 断点) + - hover / focus / active 状态 + - 图片引用 source/ 中的素材 +最终交付:HTML/CSS 文件 → output/,归档更新 index.md +``` + +### 工作流 B:APP / 产品界面设计 + +``` +Step 2 brief 含: + - 产品类型(移动 APP / Web APP / 管理后台 / SaaS 面板...) + - 核心页面清单(登录/首页/列表/详情/设置...) + - 交互模式(导航方式、手势支持、状态管理...) + - 风格参考 + - 品牌约束 +Step 3 后另写 DESIGN.md 设计规范: + - 色彩系统(语义色名 + hex + 用途:primary/secondary/surface/error/...) + - 字体系统(font-family + 层级表:display/heading/body/caption/overline) + - 间距系统(4px/8px/12px/16px/24px/32px/48px 基准) + - 组件样式规范(Button/Input/Card/Nav/Modal/Toast 等,含各状态) + - 阴影/圆角/动效规范 +Step 5 编写关键页面 HTML + CSS 原型: + - 严格遵循 DESIGN.md 中的 token + - 移动端优先(如为 APP 界面,按 375px 基准设计) + - 包含交互状态(hover/focus/disabled/loading) +最终交付:DESIGN.md + 所有页面 HTML/CSS → output/ +``` + +### 工作流 C:品牌视觉体系构建 + +``` +Step 2 brief 含: + - 品牌定位(行业、目标客群、核心价值) + - 风格方向(1-3 个关键词,如"专业+科技+温暖") + - 现有品牌资产(Logo、已有色彩偏好等) + - 应用场景(官网/APP/社交媒体/印刷品...) +Step 5 构建完整 DESIGN.md: + - Visual Theme & Atmosphere:设计哲学、情感基调、密度 + - Color Palette & Roles:语义名 + hex + 功能角色 + - Typography Rules:字体族 + 完整层级表 + - Component Stylings:核心组件样式 + 状态 + - Layout Principles:间距系统、网格、留白哲学 + - Depth & Elevation:阴影系统、表面层级 + - Responsive Behavior:断点、触控目标、折叠策略 + - Do's and Don'ts:设计护栏 +Step 5 另编写组件预览页面(preview.html): + - 展示色彩色板、字体层级、按钮/卡片/输入框等核心组件 + - 包含亮色和暗色两种表面 +最终交付:DESIGN.md + preview.html → output/ + - 将 DESIGN.md 核心信息同步到 MEMORY.md 的 Brand Assets 区 +``` + +--- + +## CSS 设计 Token 规范 + +所有 HTML/CSS 产出必须使用 CSS Custom Properties 定义设计 token: + +```css +:root { + /* 语义色彩 */ + --color-primary: oklch(...); + --color-surface: oklch(...); + --color-text: oklch(...); + + /* 字体层级 */ + --text-display: clamp(3rem, 1rem + 7vw, 8rem); + --text-body: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); + + /* 间距系统 */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + + /* 动效 */ + --duration-normal: 300ms; + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); +} +``` + +--- + +## 品牌规范应用原则 + +- 若 MEMORY.md 中有品牌色 / 字体记录 → 在 DESIGN.md 和 CSS token 中**强制指定** +- 若无 → 第一次设计后询问用户是否认可当前色彩体系,认可则记入 MEMORY.md +- 核心品牌色 / Logo 不得随意替换,其余设计 token 可根据设计系统适配 + +--- + +## 内置设计系统库 + +14 套知名品牌设计系统,每套 8 段规范(Visual Theme / Color / Typography / Components / Layout / Depth / Do's & Don'ts / Responsive): + +| 设计系统 | 风格关键词 | 适用场景 | +|---------|----------|---------| +| Stripe | 紫色渐变、优雅、金融科技 | SaaS 产品页、支付/金融科技落地页 | +| Vercel | 黑白极简、精密、Geist | 开发者工具、技术产品官网 | +| Linear | 超极简、紫色点缀、精确 | 项目管理、效率工具 | +| Notion | 暖色极简、衬线标题、柔和 | 知识管理、内容平台 | +| Apple | 极致留白、电影级影像 | 消费电子、高端品牌官网 | +| Supabase | 暗色翡翠绿、代码优先 | 数据库/后端服务、开源工具 | +| Shopify | 暗色电影感、霓虹绿 | 电商平台、商业服务 | +| Figma | 多彩活泼、专业、创意 | 创意工具、设计平台 | +| Spotify | 鲜明绿、大胆排版 | 媒体/娱乐平台 | +| Tesla | 极致减法、全屏影像 | 汽车/硬件、极简品牌 | +| Framer | 黑蓝、动效优先 | 网站构建、交互展示 | +| Airbnb | 暖色珊瑚、摄影驱动 | 旅游/生活服务、社区平台 | +| BMW | 巴伐利亚蓝、暗色奢华、金属质感 | 奢侈品牌、高端产品 | +| IBM | 企业蓝、Carbon 系统、数据密集 | 企业级产品、B2B 服务、数据平台 | +| Starbucks | Siren 绿、温暖社区、自然质感 | 生活品牌、餐饮/零售、社区平台 | + +库文件落在本技能目录 `design-systems/.md`,索引 `design-systems/index.json`。 + +### 自定义设计系统 + +内置库无法覆盖所有风格需求时,基于用户描述自行构建设计系统,输出格式参照内置 DESIGN.md 的标准 8 段结构。 + +也可从上游仓库 [VoltAgent/awesome-design-md](https://github.com/VoltAgent/awesome-design-md) 查找并导入: + +1. 访问上述仓库查看完整设计系统列表,或直接访问 `https://getdesign.md//design-md` 查看特定品牌 +2. 选取匹配的设计系统后,将内容下载为 `design-systems/.md`,补全缺失段落确保 8 段完整 +3. 在 `design-systems/index.json` 中添加条目(字段:`id` / `name` / `category` / `keywords` / `description` / `colorPrimary` / `darkMode` / `bestFor` / `file`) + +完成后即可通过 `design-full pick` 搜索到该设计系统。 + +--- + +## 子命令清单 + +| 子命令 | 用途 | 退出码 | +|--------|------|--------| +| `design-full init <任务名>` | 建任务文件夹 + brief 模板 | 0 成功 / 1 参数错 | +| `design-full pick "<风格描述>"` | 从内置库匹配 1–3 套设计系统 | 0 成功 / 1 参数错 | + +> Step 2(brief 确认)/ Step 4(素材获取)/ Step 5(HTML+CSS 编写)/ Step 6(视觉 review)/ Step 7(交付归档)由 agent 按 SKILL.md 工作流直接执行,不经本 wrapper——这些是创意判断与对话协作环节,不上脚本。 + +--- + +## 禁止事项(强制) + +- **禁止 brief 未确认就动手**:Step 2 闸门强制,确认前不得进 Step 3 +- **禁止跳过设计系统选取**:每项任务 Step 3 必跑 `design-full pick`,不得凭印象直接写 CSS +- **禁止跳过视觉 Review 交付**:Step 6 闸门强制,对照 brief + DESIGN.md 逐项查,不得裸交 +- **禁止凭空捏造品牌色**:MEMORY.md 有记录则强制遵循,无记录则设计后问用户认可才记入 diff --git a/crews/content-producer/skills/design-full/design-full.sh b/crews/content-producer/skills/design-full/design-full.sh new file mode 100755 index 00000000..4ce8f0dd --- /dev/null +++ b/crews/content-producer/skills/design-full/design-full.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# design-full.sh — design-full 顶层 wrapper(薄转发,子命令范式) +# 让 agent 用 `design-full <子命令> [参数...]` 走 PATH,零路径拼接。 +# 子命令: +# init <任务名> 建任务文件夹 + brief 模板(落 design_assets/YYYY-MM-DD-<任务名>/) +# pick "<风格描述>" 从内置设计系统库匹配最合适的 1–3 套 +# 内部转发到 scripts/ 下对应脚本;wrapper 自身只是 exec 转发,不改语义。 +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. +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +SUBCMD="${1:?用法: design-full [参数...]}" +shift +case "$SUBCMD" in + init) + exec "$SCRIPT_DIR/scripts/init.sh" "$@" + ;; + pick) + exec "$SCRIPT_DIR/scripts/pick.sh" "$@" + ;; + -h|--help|help) + cat <<'HELP' +design-full — 平面设计全案(wrapper) + +用法: + design-full init <任务名> 建任务文件夹 + brief 模板 + design-full pick "<风格描述>" 从内置设计系统库匹配最合适的 1–3 套 + design-full help 本帮助 + +子命令是 design-full SKILL.md 工作流里的原子步骤: + Step 1 建工作区 → design-full init + Step 3 设计系统选取 → design-full pick +其余步骤(brief 确认、素材获取、HTML/CSS 编写、视觉 review、交付归档)由 agent +按 SKILL.md 工作流直接执行,不经本 wrapper。 +HELP + ;; + *) + echo "未知子命令: $SUBCMD" >&2 + echo "用 design-full help 查可用子命令" >&2 + exit 1 + ;; +esac diff --git a/crews/content-producer/skills/design-system-picker/design-systems/airbnb.md b/crews/content-producer/skills/design-full/design-systems/airbnb.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/airbnb.md rename to crews/content-producer/skills/design-full/design-systems/airbnb.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/apple.md b/crews/content-producer/skills/design-full/design-systems/apple.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/apple.md rename to crews/content-producer/skills/design-full/design-systems/apple.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/bmw.md b/crews/content-producer/skills/design-full/design-systems/bmw.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/bmw.md rename to crews/content-producer/skills/design-full/design-systems/bmw.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/figma.md b/crews/content-producer/skills/design-full/design-systems/figma.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/figma.md rename to crews/content-producer/skills/design-full/design-systems/figma.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/framer.md b/crews/content-producer/skills/design-full/design-systems/framer.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/framer.md rename to crews/content-producer/skills/design-full/design-systems/framer.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/ibm.md b/crews/content-producer/skills/design-full/design-systems/ibm.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/ibm.md rename to crews/content-producer/skills/design-full/design-systems/ibm.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/index.json b/crews/content-producer/skills/design-full/design-systems/index.json similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/index.json rename to crews/content-producer/skills/design-full/design-systems/index.json diff --git a/crews/content-producer/skills/design-system-picker/design-systems/linear.md b/crews/content-producer/skills/design-full/design-systems/linear.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/linear.md rename to crews/content-producer/skills/design-full/design-systems/linear.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/notion.md b/crews/content-producer/skills/design-full/design-systems/notion.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/notion.md rename to crews/content-producer/skills/design-full/design-systems/notion.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/shopify.md b/crews/content-producer/skills/design-full/design-systems/shopify.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/shopify.md rename to crews/content-producer/skills/design-full/design-systems/shopify.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/spotify.md b/crews/content-producer/skills/design-full/design-systems/spotify.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/spotify.md rename to crews/content-producer/skills/design-full/design-systems/spotify.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/starbucks.md b/crews/content-producer/skills/design-full/design-systems/starbucks.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/starbucks.md rename to crews/content-producer/skills/design-full/design-systems/starbucks.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/stripe.md b/crews/content-producer/skills/design-full/design-systems/stripe.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/stripe.md rename to crews/content-producer/skills/design-full/design-systems/stripe.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/supabase.md b/crews/content-producer/skills/design-full/design-systems/supabase.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/supabase.md rename to crews/content-producer/skills/design-full/design-systems/supabase.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/tesla.md b/crews/content-producer/skills/design-full/design-systems/tesla.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/tesla.md rename to crews/content-producer/skills/design-full/design-systems/tesla.md diff --git a/crews/content-producer/skills/design-system-picker/design-systems/vercel.md b/crews/content-producer/skills/design-full/design-systems/vercel.md similarity index 100% rename from crews/content-producer/skills/design-system-picker/design-systems/vercel.md rename to crews/content-producer/skills/design-full/design-systems/vercel.md diff --git a/crews/content-producer/skills/init-workspace/scripts/init.sh b/crews/content-producer/skills/design-full/scripts/init.sh similarity index 85% rename from crews/content-producer/skills/init-workspace/scripts/init.sh rename to crews/content-producer/skills/design-full/scripts/init.sh index e54e7943..655e6b81 100755 --- a/crews/content-producer/skills/init-workspace/scripts/init.sh +++ b/crews/content-producer/skills/design-full/scripts/init.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# init-workspace — 为 designer 单项任务创建标准目录结构 -# 用法: ./skills/init-workspace/scripts/init.sh <任务名> -# 示例: ./skills/init-workspace/scripts/init.sh wiseflow-official-website +# init.sh — 为单项设计任务创建标准目录结构 + brief 模板 +# 用法: design-full init <任务名>(wrapper 转发到本脚本) +# 示例: design-full init wiseflow-official-website set -euo pipefail diff --git a/crews/content-producer/skills/design-system-picker/scripts/pick.sh b/crews/content-producer/skills/design-full/scripts/pick.sh similarity index 89% rename from crews/content-producer/skills/design-system-picker/scripts/pick.sh rename to crews/content-producer/skills/design-full/scripts/pick.sh index 3e02dfb2..b9064782 100755 --- a/crews/content-producer/skills/design-system-picker/scripts/pick.sh +++ b/crews/content-producer/skills/design-full/scripts/pick.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# design-system-picker — 根据风格描述从设计系统库中匹配最合适的设计系统 -# 用法: ./skills/design-system-picker/scripts/pick.sh "<风格描述>" -# 示例: ./skills/design-system-picker/scripts/pick.sh "科技感暗色主题" +# pick.sh — 从内置设计系统库匹配最合适的 1–3 套设计系统 +# 用法: design-full pick "<风格描述>"(wrapper 转发到本脚本) +# 示例: design-full pick "科技感暗色主题" set -euo pipefail diff --git a/crews/content-producer/skills/design-system-picker/SKILL.md b/crews/content-producer/skills/design-system-picker/SKILL.md deleted file mode 100644 index ebcc75b4..00000000 --- a/crews/content-producer/skills/design-system-picker/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -name: design-system-picker -description: 根据风格描述从内置设计系统库中匹配最合适的设计系统,提供设计规范供后续设计工作使用。 -metadata: - openclaw: - emoji: 🎨 ---- - -# Design System Picker - -从内置设计系统库中匹配最合适的设计系统,为后续网页/界面设计提供风格规范基础。 - -> **路径规则**:本 skill 调用的脚本和文件用**相对 skill 自身的相对路径**(`./scripts/...` / `./design-systems/...`),不依赖仓部署位置。openclaw 加载 skill 时注入 skill 根目录,agent 直接用相对路径即可。 - ---- - -## 用法 - -### 搜索匹配 - -```bash -./scripts/pick.sh "<风格描述>" -``` - -示例: - -```bash -./scripts/pick.sh "科技感暗色主题" -./scripts/pick.sh "类似 Stripe 的金融风格" -./scripts/pick.sh "温暖亲和的生活服务" -``` - -### 读取设计系统详情 - -搜索到匹配结果后,读取对应的设计系统文件获取完整规范: - -``` -读取 ./design-systems/ -``` - -例如匹配到 Stripe,则读取 `./design-systems/stripe.md`。 - ---- - -## 内置设计系统 - -| 设计系统 | 风格关键词 | 适用场景 | -|---------|----------|---------| -| Stripe | 紫色渐变、优雅、金融科技 | SaaS 产品页、支付/金融科技落地页 | -| Vercel | 黑白极简、精密、Geist | 开发者工具、技术产品官网 | -| Linear | 超极简、紫色点缀、精确 | 项目管理、效率工具 | -| Notion | 暖色极简、衬线标题、柔和 | 知识管理、内容平台 | -| Apple | 极致留白、电影级影像 | 消费电子、高端品牌官网 | -| Supabase | 暗色翡翠绿、代码优先 | 数据库/后端服务、开源工具 | -| Shopify | 暗色电影感、霓虹绿 | 电商平台、商业服务 | -| Figma | 多彩活泼、专业、创意 | 创意工具、设计平台 | -| Spotify | 鲜明绿、大胆排版 | 媒体/娱乐平台 | -| Tesla | 极致减法、全屏影像 | 汽车/硬件、极简品牌 | -| Framer | 黑蓝、动效优先 | 网站构建、交互展示 | -| Airbnb | 暖色珊瑚、摄影驱动 | 旅游/生活服务、社区平台 | -| BMW | 巴伐利亚蓝、暗色奢华、金属质感 | 奢侈品牌、高端产品 | -| IBM | 企业蓝、Carbon 系统、数据密集 | 企业级产品、B2B 服务、数据平台 | -| Starbucks | Siren 绿、温暖社区、自然质感 | 生活品牌、餐饮/零售、社区平台 | - ---- - -## 使用时机 - -每项设计任务开始时,在 brief 确认后、进入具体设计前**必须**调用此技能确定设计系统。设计系统选定后,所有后续 HTML/CSS 产出的色彩、字体、间距、组件样式都应遵循该设计系统的规范。 - ---- - -## 自定义设计系统 - -如果用户提供的风格描述与内置设计系统均不匹配,应基于用户描述自行构建设计系统,输出格式参照内置 DESIGN.md 的标准结构: - -1. Visual Theme & Atmosphere -2. Color Palette & Roles -3. Typography Rules -4. Component Stylings -5. Layout Principles -6. Depth & Elevation -7. Do's and Don'ts -8. Responsive Behavior - ---- - -## 探索更多设计系统 - -内置设计系统无法覆盖所有风格需求。当内置库中没有合适匹配,或用户指定了特定品牌/风格参考时,可从上游仓库 [VoltAgent/awesome-design-md](https://github.com/VoltAgent/awesome-design-md) 中查找并导入: - -### 查找流程 - -1. 访问 `https://github.com/VoltAgent/awesome-design-md` 查看完整设计系统列表 -2. 也可直接访问 `https://getdesign.md//design-md` 查看特定品牌的设计系统(如 `https://getdesign.md/starbucks/design-md`) -3. 选取匹配的设计系统后,将内容下载为 `./design-systems/.md` - -### 导入流程 - -找到合适的设计系统后,必须完成以下两步才能使用: - -**1. 添加设计系统文件** - -将 DESIGN.md 内容保存到 `./design-systems/.md`,确保遵循标准的 8 段结构。如果不完整,应基于下载内容补全缺失段落。 - -**2. 注册到索引** - -在 `./design-systems/index.json` 中添加条目: - -```json -{ - "id": "", - "name": "", - "category": "", - "keywords": ["关键词1", "关键词2", ...], - "description": "一句话风格描述", - "colorPrimary": "#HEX", - "darkMode": true/false, - "bestFor": "适用场景描述", - "file": ".md" -} -``` - -完成后即可通过 `./scripts/pick.sh` 搜索到该设计系统。 \ No newline at end of file diff --git a/crews/content-producer/skills/design-system-picker/design-system-picker.sh b/crews/content-producer/skills/design-system-picker/design-system-picker.sh deleted file mode 100755 index 9ccdb34e..00000000 --- a/crews/content-producer/skills/design-system-picker/design-system-picker.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# design-system-picker.sh — design-system-picker 顶层 wrapper(薄转发) -# 让 agent 用 `design-system-picker ` 走 PATH,零路径拼接。 -# 内部转发到 scripts/pick.sh;wrapper 自身只是 exec 转发,不改语义。 -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. -while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done -SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -exec "$SCRIPT_DIR/scripts/pick.sh" "$@" diff --git a/crews/content-producer/skills/html-video/SKILL.md b/crews/content-producer/skills/html-video/SKILL.md deleted file mode 100644 index 82c3c18c..00000000 --- a/crews/content-producer/skills/html-video/SKILL.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -name: html-video -description: 使用 html-video 引擎从 content-graph 和模板生成视频。支持 23+ 模板、多种画面比例、变量注入、逐帧渲染、全项目导出。TTS/BGM 由 openclaw MiniMax 扩展提供,不使用 html-video 内置音频。 -metadata: - openclaw: - emoji: 🎬 - requires: - bins: - - node - - ffmpeg ---- - -# html-video — 模板驱动视频生成 - -## 概述 - -基于 html-video 引擎的视频生成技能。核心能力: - -- **23+ 模板库**:覆盖标题动画、数据可视化、产品宣传、结尾 CTA、社交短视频等场景 -- **多画面比例**:9:16、16:9、1:1、4:5 等 -- **Content-Graph IR**:结构化分镜(nodes + edges + topo-sort) -- **确定性渲染**:animation freeze → font loading → duration probe → Chromium 录制 → ffmpeg 编码 -- **全项目导出**:逐帧渲染 → 帧拼接 → 音频混合(ducking + fades) - -**TTS/BGM 说明**:音频生成由 openclaw 的 MiniMax 扩展提供(speech-2.8-hd / music-2.6),不使用 html-video 内置的 MiniMax provider。生成后的音频文件作为项目资产注入 html-video 的 `applySoundtrack`。 - -## CLI 调用 - -> **⚠️ 调用规范(必须遵守)** -> -> - **必须**通过 `./skills/html-video/scripts/hv.sh ` 调用,hv.sh 内部自动解析 CLI 路径并 `exec node` -> - **禁止**直接 `node .../bin.js` — 路径易错且绕过 allowlist -> - **禁止** `python3 .../bin.js` — bin.js 是 ESM JavaScript,不是 Python 脚本 -> - **禁止** `bash hv.sh` 或绝对路径调用 — 使用工作区相对路径 `./skills/html-video/scripts/hv.sh` -> - **禁止**直接 `ffmpeg` / `ffprobe` — 由 hv.sh 内部 subprocess 调用,agent 直接调有 CPU 卡死风险 - -所有操作通过 `hv.sh` 包装脚本调用: - -```bash -# 环境检查 -./skills/html-video/scripts/hv.sh doctor - -# 模板搜索 -./skills/html-video/scripts/hv.sh search-templates --intent "title animation" - -# 查看模板详情 -./skills/html-video/scripts/hv.sh inspect-template frame-glitch-title - -# 项目管理 -./skills/html-video/scripts/hv.sh project-create --name "my-video" --aspect "9:16" -./skills/html-video/scripts/hv.sh project-set-template --template -./skills/html-video/scripts/hv.sh project-set-var --key title --value '"文案"' -./skills/html-video/scripts/hv.sh project-set-var --key duration_sec --value 4 - -# 渲染 -./skills/html-video/scripts/hv.sh project-render --output /path/to/output.mp4 - -# 项目查询 -./skills/html-video/scripts/hv.sh project-list -./skills/html-video/scripts/hv.sh project-show -``` - -## 画面比例 - -| 比例 | 分辨率 | 典型场景 | -|------|--------|---------| -| `9:16` | 1080×1920 | 短视频、竖屏(默认) | -| `16:9` | 1920×1080 | 横屏视频、YouTube | -| `1:1` | 1080×1080 | Instagram 方形 | -| `4:5` | 1080×1350 | Instagram 竖屏 | - -创建项目时通过 `--aspect` 指定,未指定默认 `9:16`。 - -## 工作流 - -### 1. Content-Graph 生成 - -分析脚本内容,自行决定分段,生成 content-graph.json: - -```json -{ - "schemaVersion": 1, - "intent": "promo", - "synopsis": "视频概要", - "nodes": [ - { - "id": "hook-title", - "kind": "text", - "frameIntent": "intro", - "durationSec": 4, - "templateRef": "frame-glitch-title", - "variables": { "title": "...", "subtitle": "..." }, - "hasTts": true, - "ttsText": "配音文案" - }, - { - "id": "product-clip", - "kind": "entity", - "frameIntent": "image-pan", - "durationSec": 8, - "templateRef": "video-clip-916", - "variables": { "videoSrc": "assets/clip.mp4" }, - "hasTts": true, - "ttsText": "产品介绍文案" - } - ], - "edges": [ - { "from": "hook-title", "to": "product-clip", "kind": "sequence" } - ] -} -``` - -### 2. 素材预获取 - -素材类节点(如 video-clip)需要先获取素材 MP4: - -**获取优先级**(按顺序尝试,成功即停): - -1. **用户预置素材**:`assets/` 中已有对应素材 → 直接使用 -2. **`video_generate` 工具**:根据画面需求撰写 prompt,生成后验证时长 -3. **`siliconflow-video-gen`**:AI 视频生成(每次 5 秒,可能需多次生成后拼接) -⚠️:`siliconflow-video-gen`只要失败一次,第二次马上降级使用`pexels-footage`或者`pixabay-footage`,绝不允许连续多次调用`siliconflow-video-gen`,以避免触发系统锁死 -4. **`pixabay-footage`**:从 Pixabay 免费素材库搜索下载 -5. **`pexels-footage`**:Pixabay 无合适结果时,从 Pexels 搜索下载 - -素材下载规则: - -- **一次只下载一个视频**:pixabay-footage 和 pexels-footage 脚本已强制 `--max-clips=1` -- **时长精准匹配**:根据节点目标时长设置 `--min-duration` 和 `--max-duration`,不下载远超需求的素材 -- 下载后用 ffprobe 确认实际时长,写入节点 `duration` 字段 - -### 3. 模板变量注入 - -所有节点的变量通过 `project-set-var` 注入 html-video 项目。素材节点的 `videoSrc` 替换为 Step 2 获取的 `clip.mp4` 路径,`duration` 替换为 ffprobe 检测的实际时长。 - -### 4. TTS 生成 - -- 主力:openclaw MiniMax 扩展(speech-2.8-hd,5 种中文音色) - - 通过 `tts` 工具调用 - - TokenPlan 订阅 key: `MINIMAX_CODE_PLAN_KEY` -- Fallback:SiliconFlow TTS (MOSS-TTSD-v0.5) - - 通过 `siliconflow-tts` 技能调用,详见 `siliconflow-tts/SKILL.md` -- BGM:openclaw MiniMax 扩展(music-2.6) - - 通过 `music_generate` 工具调用 -- 生成的音频文件写入项目资产目录,html-video 的 `applySoundtrack` 负责最终混音 - -### 5. 全项目渲染 - -```bash -./skills/html-video/scripts/hv.sh project-render --output final/video.mp4 -``` - -html-video 自动完成:逐帧渲染 → 帧拼接 → 音频混合。 - -## 可用模板 - -### 标题 / 呈现类(presentation) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `frame-glitch-title` | Glitch Title | 3-8s | 科技产品揭示、赛博朋克风格 | -| `frame-kinetic-type` | Kinetic Type | 3-30s | 推广标题、醒目声明 | -| `frame-bold-poster` | Bold Poster | 4-6s | 品牌宣言、杂志封面式开场 | -| `frame-bold-signal` | Bold Signal | 3-6s | 章节分隔、强冲击标题卡 | -| `frame-build-minimal` | Build Minimal | 4-7s | 高端产品/品牌 hero、优雅标题卡 | -| `frame-creative-voltage` | Creative Voltage | 3-6s | 活力品牌/活动标题、手绘风格 | -| `frame-electric-studio` | Electric Studio | 3-6s | 引用/证言揭示、使命声明卡 | -| `frame-warm-grain` | Warm Grain | 3-30s | 产品发布、生活方式品牌 | -| `frame-swiss-grid` | Swiss Grid | 3-30s | 企业幻灯片、极简报告卡 | -| `frame-vignelli` | Vignelli | 3-30s | 社交竖屏、醒目声明卡 | -| `vfx-text-cursor` | Text + Cursor VFX | 3-10s | 代码演示开场、科技叙事 | - -### 数据可视化类(data-viz) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `frame-data-chart-nyt` | NYT Data Chart | 5-20s | 编辑数据可视化、年报、对比揭示 | -| `frame-data-rollup` | Data Rollup | 3-8s | 数据动画、周报指标、增长柱状图 | -| `frame-nyt-graph` | NYT Graph | 3-30s | 新闻式统计揭示、折线图 | -| `frame-pentagram-stat` | Pentagram Stat | 3-6s | 单一核心指标/基准揭示、编辑数据幻灯 | - -### 产品 / 营销类(marketing / product-demo) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `frame-product-promo` | Product Promo | 3-30s | 产品展示、多功能轮播、hero 推广 | -| `frame-product-promo-30s` | Product Promo · 30s | 25-35s | 30 秒产品推广、B2B SaaS 发布 | -| `frame-liquid-bg-hero` | Liquid Background Hero | 4-12s | 产品发布 hero、SaaS 落地视频 | -| `frame-play-mode` | Play Mode | 3-30s | 轻松社交广告、休闲开场 | - -### 讲解 / 氛围类(explainer / ambient) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `frame-decision-tree` | Decision Tree | 3-30s | 操作流程、决策分支 | -| `frame-takram-organic` | Takram Organic | 4-7s | 系统/架构概念揭示、温暖产品故事 | -| `frame-light-leak-cinema` | Light Leak Cinema | 4-10s | 电影感开场、纪录片冷开场 | - -### 素材帧类(stock-clip) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `video-clip-916` | Video Clip 9:16 | 由素材时长决定 | 9:16 竖屏素材视频播放、片段嵌入 | - -> 本地 workspace 模板(`templates/` 目录下)使用 `-` 命名,由 `registry.py` 解析。html-video CLI 模板使用 `frame-` 前缀。在 content-graph 的 `templateRef` 中使用对应的模板 ID。 - -### 结尾类(intro-outro) - -| 模板 ID | 名称 | 时长 | 适用场景 | -|---------|------|------|---------| -| `frame-logo-outro` | Logo Outro Frame | 3-10s | 视频结尾卡、品牌 outro、频道签退 | - -> **模板选择提示**:使用 `hv.sh search-templates --intent "<意图>"` 搜索最匹配的模板。例如 `--intent "product launch"` 会推荐 `frame-liquid-bg-hero` 和 `frame-product-promo`。 - -## 环境变量 - -| 变量 | 必需 | 说明 | -|------|------|------| -| `MINIMAX_CODE_PLAN_KEY` | 推荐 | MiniMax TokenPlan 订阅 key(openclaw 扩展自动识别) | -| `SILICONFLOW_API_KEY` | fallback | SiliconFlow TTS 备选 | -| `HTML_VIDEO_CLI` | 可选 | html-video CLI 路径(默认自动查找) | diff --git a/crews/content-producer/skills/html-video/scripts/content_graph.py b/crews/content-producer/skills/html-video/scripts/content_graph.py deleted file mode 100644 index c4b4463d..00000000 --- a/crews/content-producer/skills/html-video/scripts/content_graph.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -"""Content-Graph IR for content-producer html-video workflow. - -Usage: - python3 content_graph.py validate — Validate content-graph - python3 content_graph.py topo-sort — Topological sort nodes - python3 content_graph.py to-frames — Convert to frame list (for rendering) -""" -import sys -import json -from pathlib import Path - -def validate_graph(graph: dict) -> list[str]: - """Validate content-graph structure. Returns list of errors.""" - errors = [] - - if graph.get("schemaVersion") != 1: - errors.append("schemaVersion must be 1") - - if "nodes" not in graph or not isinstance(graph["nodes"], list): - errors.append("nodes must be a list") - return errors - - if len(graph["nodes"]) == 0: - errors.append("nodes cannot be empty") - - node_ids = set() - for i, node in enumerate(graph["nodes"]): - nid = node.get("id") - if not nid: - errors.append(f"node[{i}] missing id") - continue - if nid in node_ids: - errors.append(f"node[{i}] duplicate id: {nid}") - node_ids.add(nid) - - if node.get("kind") not in ("text", "entity", "data"): - errors.append(f"node '{nid}': kind must be text/entity/data") - - if "templateRef" not in node: - errors.append(f"node '{nid}': missing templateRef") - - # Validate edges - if "edges" in graph: - for i, edge in enumerate(graph["edges"]): - if edge.get("from") not in node_ids: - errors.append(f"edge[{i}]: 'from' references unknown node '{edge.get('from')}'") - if edge.get("to") not in node_ids: - errors.append(f"edge[{i}]: 'to' references unknown node '{edge.get('to')}'") - if edge.get("kind") not in ("sequence", "dependency", "contrast"): - errors.append(f"edge[{i}]: kind must be sequence/dependency/contrast") - if edge.get("from") == edge.get("to"): - errors.append(f"edge[{i}]: self-edge on '{edge.get('from')}'") - - # Check for cycles in dependency edges - if "edges" in graph: - dep_edges = [(e["from"], e["to"]) for e in graph["edges"] if e.get("kind") == "dependency"] - # Kahn's algorithm for cycle detection - in_degree = {nid: 0 for nid in node_ids} - adj = {nid: [] for nid in node_ids} - for frm, to in dep_edges: - adj[frm].append(to) - in_degree[to] = in_degree.get(to, 0) + 1 - - queue = [nid for nid in node_ids if in_degree[nid] == 0] - visited = 0 - while queue: - node = queue.pop(0) - visited += 1 - for neighbor in adj[node]: - in_degree[neighbor] -= 1 - if in_degree[neighbor] == 0: - queue.append(neighbor) - - if visited < len(node_ids): - errors.append("cycle detected in dependency edges") - - return errors - -def topo_sort(graph: dict) -> list[str]: - """Topological sort using Kahn's algorithm with sequence-edge preference.""" - nodes = graph.get("nodes", []) - edges = graph.get("edges", []) - - node_ids = [n["id"] for n in nodes] - node_order = {nid: i for i, nid in enumerate(node_ids)} - - # Build adjacency from dependency edges only - in_degree = {nid: 0 for nid in node_ids} - adj = {nid: [] for nid in node_ids} - - for edge in edges: - frm, to, kind = edge.get("from"), edge.get("to"), edge.get("kind") - if kind == "dependency" and frm in node_order and to in node_order: - adj[frm].append(to) - in_degree[to] = in_degree.get(to, 0) + 1 - - # Kahn's algorithm - result = [] - available = [nid for nid in node_ids if in_degree[nid] == 0] - - # Sort available by sequence-edge preference, then original order - seq_order = {} - for edge in edges: - if edge.get("kind") == "sequence": - frm, to = edge.get("from"), edge.get("to") - if frm in node_order and to in node_order: - seq_order[to] = frm - - def sort_key(nid): - # Nodes that are sequence-targets of already-sorted nodes come first - return node_order.get(nid, 999) - - available.sort(key=sort_key) - - while available: - node = available.pop(0) - result.append(node) - for neighbor in adj[node]: - in_degree[neighbor] -= 1 - if in_degree[neighbor] == 0: - available.append(neighbor) - available.sort(key=sort_key) - - return result - -def to_frames(graph: dict) -> list[dict]: - """Convert content-graph to ordered frame list for rendering.""" - order = topo_sort(graph) - nodes_by_id = {n["id"]: n for n in graph.get("nodes", [])} - - frames = [] - for i, nid in enumerate(order): - node = nodes_by_id.get(nid) - if not node: - continue - frame = { - "order": i + 1, - "id": nid, - "templateRef": node.get("templateRef", ""), - "variables": node.get("variables", {}), - "durationSec": node.get("durationSec", 5), - "hasTts": node.get("hasTts", False), - "ttsText": node.get("ttsText", ""), - "ttsVoice": node.get("ttsVoice", ""), - "frameIntent": node.get("frameIntent", ""), - "label": node.get("label", ""), - } - frames.append(frame) - - return frames - -def main(): - if len(sys.argv) < 3: - print(__doc__) - sys.exit(1) - - cmd = sys.argv[1] - graph_path = sys.argv[2] - - with open(graph_path, "r", encoding="utf-8") as f: - graph = json.load(f) - - if cmd == "validate": - errors = validate_graph(graph) - if errors: - print("VALIDATION FAILED:") - for e in errors: - print(f" ❌ {e}") - sys.exit(1) - else: - print("VALIDATION PASSED ✓") - - elif cmd == "topo-sort": - order = topo_sort(graph) - print(json.dumps(order, ensure_ascii=False, indent=2)) - - elif cmd == "to-frames": - frames = to_frames(graph) - print(json.dumps(frames, ensure_ascii=False, indent=2)) - - else: - print(f"Unknown command: {cmd}") - print(__doc__) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/html-video/scripts/hv.sh b/crews/content-producer/skills/html-video/scripts/hv.sh deleted file mode 100755 index 3035e2b1..00000000 --- a/crews/content-producer/skills/html-video/scripts/hv.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# hv.sh — html-video CLI wrapper for content-producer -# Usage: hv.sh [options] -# hv.sh doctor -# hv.sh search-templates --intent "title animation" -# hv.sh project-create --name "my-video" --aspect "9:16" -# hv.sh project-set-template --template -# hv.sh project-set-var --key title --value '"Hello"' -# hv.sh project-render --output /path/to/output.mp4 -# hv.sh project-list -# hv.sh project-show -# hv.sh project-delete - -set -euo pipefail - -# Resolve html-video CLI path -# Priority: env var > workspace-level clone > fail -HV_CLI="${HTML_VIDEO_CLI:-}" - -if [ -z "$HV_CLI" ]; then - # Look for html-video in the wiseflow-pro workspace - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - # Traverse up to find workspace root (contains html-video/) - SEARCH_DIR="$SCRIPT_DIR" - for _ in {1..10}; do - if [ -d "$SEARCH_DIR/html-video/packages/cli/dist" ]; then - HV_CLI="$SEARCH_DIR/html-video/packages/cli/dist/bin.js" - break - fi - SEARCH_DIR="$(dirname "$SEARCH_DIR")" - done -fi - -if [ -z "$HV_CLI" ] || [ ! -f "$HV_CLI" ]; then - echo "ERROR: html-video CLI not found. Set HTML_VIDEO_CLI env var or clone html-video to workspace." >&2 - exit 1 -fi - -# Set CWD to html-video project root (where templates/ and projects/ live) -HV_ROOT="$(dirname "$(dirname "$(dirname "$HV_CLI")")")" - -exec node "$HV_CLI" --cwd "$HV_ROOT" "$@" diff --git a/crews/content-producer/skills/html-video/templates/data-chart-916/source/index.html b/crews/content-producer/skills/html-video/templates/data-chart-916/source/index.html deleted file mode 100644 index 381249c7..00000000 --- a/crews/content-producer/skills/html-video/templates/data-chart-916/source/index.html +++ /dev/null @@ -1,198 +0,0 @@ - - - - - -Data Chart 9:16 - - - - -
- -
PLACEHOLDER_KICKER
-
PLACEHOLDER_TITLE
-
- - -
-
-
0
-
-
2021
-
-
-
0
-
-
2022
-
-
-
0
-
-
2023
-
-
-
0
-
-
2024
-
-
-
0
-
-
2025
-
-
- -
- -
数据来源:PLACEHOLDER_SOURCE
-
- - - - diff --git a/crews/content-producer/skills/html-video/templates/data-chart-916/template.yaml b/crews/content-producer/skills/html-video/templates/data-chart-916/template.yaml deleted file mode 100644 index dcc3fae0..00000000 --- a/crews/content-producer/skills/html-video/templates/data-chart-916/template.yaml +++ /dev/null @@ -1,71 +0,0 @@ -spec_version: 1 -id: data-chart-916 -name: Data Chart 9:16 -description: > - NYT 编辑风格数据可视化模板,竖向柱状图 + 计数器动画。 - 9:16 竖屏版本,支持中文。适用于数据对比、增长趋势、年度报告等场景。 - -engine: hyperframes -engine_version: ^0.4.0 -source_entry: source/index.html - -category: data-visualization -subcategory: bar-chart -tags: [chart, data, bar, nyt, editorial, 9:16] - -best_for: - - "数据对比展示" - - "增长趋势动画" - - "年度数据报告" - - "编辑风格数据可视化" - -output: - formats: [mp4, webm] - default_format: mp4 - resolution: - default: { width: 1080, height: 1920 } - supported_aspects: ["9:16"] - fps: - default: 30 - supported: [30, 60] - duration: - type: variable - min_sec: 3 - max_sec: 10 - alpha: false - audio: - supported: true - expected_inputs: [narration] - -inputs: - schema: - type: object - required: [title] - properties: - title: - type: string - maxLength: 40 - description: "图表主标题" - kicker: - type: string - maxLength: 30 - description: "类别标签(标题上方红色小字)" - source: - type: string - maxLength: 60 - description: "数据来源说明" - durationSec: - type: number - minimum: 3 - maximum: 10 - default: 5 - examples: - - { "title": "用户增长突破九百万", "kicker": "年度增长", "source": "公司年报 2025", "durationSec": 5 } - -license: - spdx: Apache-2.0 - attribution_required: false - redistribution_allowed: true - commercial_use: true - -version: 0.1.0 diff --git a/crews/content-producer/skills/html-video/templates/glitch-title-916/source/index.html b/crews/content-producer/skills/html-video/templates/glitch-title-916/source/index.html deleted file mode 100644 index ae1aa2f1..00000000 --- a/crews/content-producer/skills/html-video/templates/glitch-title-916/source/index.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - -Glitch Title 9:16 - - - - - - - - - - - - - - - - - - -
- >> GLITCH · CH-04 - REC ● -
- - -
-
— PLACEHOLDER_SUBTITLE —
-
-

- PLACEHOLDER_TITLE -

-

- PLACEHOLDER_TITLE -

-

- PLACEHOLDER_TITLE -

-
-
- -
-
- - -
- HTML-VIDEO / GLITCH - CHROMATIC · CYAN × MAGENTA -
- - diff --git a/crews/content-producer/skills/html-video/templates/glitch-title-916/template.yaml b/crews/content-producer/skills/html-video/templates/glitch-title-916/template.yaml deleted file mode 100644 index d8ed7358..00000000 --- a/crews/content-producer/skills/html-video/templates/glitch-title-916/template.yaml +++ /dev/null @@ -1,66 +0,0 @@ -spec_version: 1 -id: glitch-title-916 -name: Glitch Title 9:16 -description: > - 赛博朋克风格故障标题动画,RGB 偏移 + 扫描线 + 色差效果。 - 9:16 竖屏版本,支持中文标题。适用于开篇 hook 片段。 - -engine: hyperframes -engine_version: ^0.4.0 -source_entry: source/index.html - -category: title-animation -subcategory: text-card -tags: [title, glitch, cyberpunk, hook, 9:16] - -best_for: - - "开篇标题动画" - - "科技产品发布" - - "赛博朋克风格" - -output: - formats: [mp4, webm] - default_format: mp4 - resolution: - default: { width: 1080, height: 1920 } - supported_aspects: ["9:16"] - fps: - default: 30 - supported: [30, 60] - duration: - type: variable - min_sec: 3 - max_sec: 8 - alpha: false - audio: - supported: true - expected_inputs: [narration] - -inputs: - schema: - type: object - required: [title] - properties: - title: - type: string - maxLength: 30 - description: "主标题文案(建议简短有力)" - subtitle: - type: string - maxLength: 50 - description: "副标题/补充文案" - duration_sec: - type: number - minimum: 3 - maximum: 8 - default: 4 - examples: - - { "title": "99%的人都不知道", "subtitle": "这个赚钱方法", "duration_sec": 4 } - -license: - spdx: Apache-2.0 - attribution_required: false - redistribution_allowed: true - commercial_use: true - -version: 0.1.0 diff --git a/crews/content-producer/skills/html-video/templates/logo-outro-916/source/index.html b/crews/content-producer/skills/html-video/templates/logo-outro-916/source/index.html deleted file mode 100644 index a8a5c01a..00000000 --- a/crews/content-producer/skills/html-video/templates/logo-outro-916/source/index.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - -Logo Outro 9:16 - - - - -
- - -
-
-
-
-
-
- - -
PLACEHOLDER_BRAND_NAME
- - -
PLACEHOLDER_TAGLINE
- - -
PLACEHOLDER_CTA
- - -
-
- - diff --git a/crews/content-producer/skills/html-video/templates/logo-outro-916/template.yaml b/crews/content-producer/skills/html-video/templates/logo-outro-916/template.yaml deleted file mode 100644 index 860e0ca2..00000000 --- a/crews/content-producer/skills/html-video/templates/logo-outro-916/template.yaml +++ /dev/null @@ -1,70 +0,0 @@ -spec_version: 1 -id: logo-outro-916 -name: Logo Outro 9:16 -description: > - 品牌 Logo 组装动画 + 品牌名 + 标语 + CTA 尾帧模板。 - 9:16 竖屏版本,支持中文。适用于视频结尾品牌露出与行动号召。 - -engine: hyperframes -engine_version: ^0.4.0 -source_entry: source/index.html - -category: outro -subcategory: brand-cta -tags: [outro, logo, brand, cta, 9:16] - -best_for: - - "视频结尾品牌露出" - - "行动号召尾帧" - - "品牌 Logo 动画" - -output: - formats: [mp4, webm] - default_format: mp4 - resolution: - default: { width: 1080, height: 1920 } - supported_aspects: ["9:16"] - fps: - default: 30 - supported: [30, 60] - duration: - type: variable - min_sec: 3 - max_sec: 8 - alpha: false - audio: - supported: true - expected_inputs: [narration] - -inputs: - schema: - type: object - required: [brandName] - properties: - brandName: - type: string - maxLength: 20 - description: "品牌名称" - tagline: - type: string - maxLength: 40 - description: "品牌标语/一句话定位" - cta: - type: string - maxLength: 20 - description: "行动号召文案(如:立即体验、关注我们)" - durationSec: - type: number - minimum: 3 - maximum: 8 - default: 4 - examples: - - { "brandName": "WiseFlow", "tagline": "让信息为你所用", "cta": "立即体验", "durationSec": 4 } - -license: - spdx: Apache-2.0 - attribution_required: false - redistribution_allowed: true - commercial_use: true - -version: 0.1.0 diff --git a/crews/content-producer/skills/html-video/templates/registry.py b/crews/content-producer/skills/html-video/templates/registry.py deleted file mode 100644 index f738d74f..00000000 --- a/crews/content-producer/skills/html-video/templates/registry.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -"""Template registry for content-producer html-video 9:16 templates. - -Usage: - python3 registry.py list — List all templates - python3 registry.py search — Search by intent keyword - python3 registry.py inspect — Show template details - python3 registry.py inject — Inject variables into template -""" -import sys -import os -import json -import re -import shutil -from pathlib import Path - -TEMPLATES_DIR = Path(__file__).parent - -def load_manifest(template_dir: Path) -> dict | None: - yaml_path = template_dir / "template.yaml" - if not yaml_path.exists(): - return None - # Minimal YAML parser (no dependency) - text = yaml_path.read_text(encoding="utf-8") - manifest = {} - current_key = None - for line in text.splitlines(): - if line.startswith("spec_version:"): - manifest["spec_version"] = line.split(":", 1)[1].strip() - elif line.startswith("id:"): - manifest["id"] = line.split(":", 1)[1].strip() - elif line.startswith("name:"): - manifest["name"] = line.split(":", 1)[1].strip() - elif line.startswith("engine:"): - manifest["engine"] = line.split(":", 1)[1].strip() - elif line.startswith("category:"): - manifest["category"] = line.split(":", 1)[1].strip() - elif line.startswith(" default:"): - if current_key == "resolution": - manifest["default_resolution"] = line.split("default:", 1)[1].strip() - elif line.startswith(" min_sec:"): - manifest["min_sec"] = int(line.split(":", 1)[1].strip()) - elif line.startswith(" max_sec:"): - manifest["max_sec"] = int(line.split(":", 1)[1].strip()) - elif line.strip().startswith("resolution:"): - current_key = "resolution" - manifest["dir"] = str(template_dir) - return manifest - -def list_templates() -> list[dict]: - templates = [] - for d in sorted(TEMPLATES_DIR.iterdir()): - if d.is_dir() and (d / "template.yaml").exists(): - m = load_manifest(d) - if m: - templates.append(m) - return templates - -def search_templates(intent: str) -> list[dict]: - all_t = list_templates() - results = [] - intent_lower = intent.lower() - for t in all_t: - score = 0 - searchable = f"{t.get('id','')} {t.get('name','')} {t.get('category','')}".lower() - for word in intent_lower.split(): - if word in searchable: - score += 1 - if score > 0: - t["score"] = score - results.append(t) - results.sort(key=lambda x: x.get("score", 0), reverse=True) - return results - -def inject_template(template_id: str, output_dir: str, variables: dict) -> str: - """Inject variables into template HTML and write to output_dir.""" - template_dir = TEMPLATES_DIR / template_id - if not template_dir.exists(): - raise FileNotFoundError(f"Template not found: {template_id}") - - source_html = template_dir / "source" / "index.html" - if not source_html.exists(): - raise FileNotFoundError(f"Template source not found: {source_html}") - - html = source_html.read_text(encoding="utf-8") - - # Replace PLACEHOLDER_* with variable values - for key, value in variables.items(): - placeholder = f"PLACEHOLDER_{key.upper()}" - html = html.replace(placeholder, str(value)) - - # Write output - out_path = Path(output_dir) - out_path.mkdir(parents=True, exist_ok=True) - output_file = out_path / "index.html" - output_file.write_text(html, encoding="utf-8") - - return str(output_file) - -def main(): - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) - - cmd = sys.argv[1] - - if cmd == "list": - for t in list_templates(): - print(f" {t['id']:30s} {t.get('name',''):25s} {t.get('category',''):15s} {t.get('min_sec','?')}-{t.get('max_sec','?')}s") - - elif cmd == "search": - if len(sys.argv) < 3: - print("Usage: registry.py search ") - sys.exit(1) - results = search_templates(sys.argv[2]) - if not results: - print("No matches found.") - for t in results: - print(f" {t['id']:30s} score={t['score']} {t.get('name','')}") - - elif cmd == "inspect": - if len(sys.argv) < 3: - print("Usage: registry.py inspect ") - sys.exit(1) - template_dir = TEMPLATES_DIR / sys.argv[2] - yaml_path = template_dir / "template.yaml" - if yaml_path.exists(): - print(yaml_path.read_text(encoding="utf-8")) - else: - print(f"Template not found: {sys.argv[2]}") - - elif cmd == "inject": - if len(sys.argv) < 5: - print("Usage: registry.py inject ") - sys.exit(1) - template_id = sys.argv[2] - outdir = sys.argv[3] - variables = json.loads(sys.argv[4]) - result = inject_template(template_id, outdir, variables) - print(f"Injected: {result}") - - else: - print(f"Unknown command: {cmd}") - print(__doc__) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/html-video/templates/video-clip-916/source/index.html b/crews/content-producer/skills/html-video/templates/video-clip-916/source/index.html deleted file mode 100644 index b34a5712..00000000 --- a/crews/content-producer/skills/html-video/templates/video-clip-916/source/index.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - -Video Clip 9:16 - - - - -
- - - -
- -
-
PLACEHOLDER_TITLE
-
PLACEHOLDER_SUBTITLE
-
-
- - - - - diff --git a/crews/content-producer/skills/html-video/templates/video-clip-916/template.yaml b/crews/content-producer/skills/html-video/templates/video-clip-916/template.yaml deleted file mode 100644 index e401834b..00000000 --- a/crews/content-producer/skills/html-video/templates/video-clip-916/template.yaml +++ /dev/null @@ -1,76 +0,0 @@ -spec_version: 1 -id: video-clip-916 -name: Video Clip 9:16 -description: > - 素材视频播放模板。将 MP4 视频片段包装为 HTML 帧, - 可选文字叠加(标题/副标题),统一纳入 html-video 渲染管线。 - 用于:用户提供素材 / AI 生成视频 / 素材库下载片段。 - -engine: hyperframes -engine_version: ^0.4.0 -source_entry: source/index.html - -category: video-clip -subcategory: wrapper -tags: [video, clip, stock, wrapper, 9:16] - -best_for: - - "素材视频片段" - - "AI 生成视频" - - "用户提供视频" - - "产品展示实拍" - -output: - formats: [mp4, webm] - default_format: mp4 - resolution: - default: { width: 1080, height: 1920 } - supported_aspects: ["9:16"] - fps: - default: 30 - supported: [24, 30, 60] - duration: - type: variable - min_sec: 3 - max_sec: 30 - alpha: false - audio: - supported: true - expected_inputs: [narration] - -inputs: - schema: - type: object - required: [videoSrc, durationSec] - properties: - videoSrc: - type: string - description: "视频文件路径(相对于项目目录或绝对路径)" - durationSec: - type: number - minimum: 3 - maximum: 30 - description: "视频时长(秒),用于 data-duration" - title: - type: string - maxLength: 30 - description: "可选:叠加标题文字" - subtitle: - type: string - maxLength: 60 - description: "可选:叠加副标题文字" - overlayPosition: - type: string - enum: [bottom, center, top] - default: bottom - description: "文字叠加位置" - examples: - - { "videoSrc": "assets/clip.mp4", "durationSec": 8, "title": "一键搞定", "overlayPosition": "bottom" } - -license: - spdx: Apache-2.0 - attribution_required: false - redistribution_allowed: true - commercial_use: true - -version: 0.1.0 diff --git a/crews/content-producer/skills/init-workspace/SKILL.md b/crews/content-producer/skills/init-workspace/SKILL.md deleted file mode 100644 index 7f77e1df..00000000 --- a/crews/content-producer/skills/init-workspace/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: init-workspace -description: 为单项设计任务创建标准目录结构和 brief 模板。每次接到设计需求时首先调用。 -metadata: - openclaw: - emoji: 📁 ---- - -# Init Workspace - -为每一项设计任务创建独立的文件夹和 brief 模板。 - -## 用法 - -```bash -/home/wukong/wiseflow-pro/crews/content-producer/skills/init-workspace/scripts/init.sh <任务名> -``` - -示例: - -```bash -/home/wukong/wiseflow-pro/crews/content-producer/skills/init-workspace/scripts/init.sh xiaobei-launch-poster -``` - -## 产出 - -在 `design_assets/` 下创建 `YYYY-MM-DD-<任务名>/` 目录,包含: - -``` -design_assets/YYYY-MM-DD-<任务名>/ -├── brief.md # 设计需求模板(待填写) -├── prompts.json # 生图参数记录 -├── source/ # 原始素材(参考图、品牌资产等) -└── output/ # 成品输出 -``` - -## 使用时机 - -每项设计任务开始前**必须**调用此脚本,确保所有产出有独立归档。 diff --git a/crews/content-producer/skills/init-workspace/init-workspace.sh b/crews/content-producer/skills/init-workspace/init-workspace.sh deleted file mode 100755 index b5586765..00000000 --- a/crews/content-producer/skills/init-workspace/init-workspace.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# init-workspace.sh — init-workspace 顶层 wrapper(薄转发) -# 让 agent 用 `init-workspace ` 走 PATH,零路径拼接。 -# 内部转发到 scripts/init.sh;wrapper 自身只是 exec 转发,不改语义。 -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. -while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done -SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" -exec "$SCRIPT_DIR/scripts/init.sh" "$@" diff --git a/crews/content-producer/skills/manim-explainer/SKILL.md b/crews/content-producer/skills/manim-explainer/SKILL.md index 39abb68c..0ac931b9 100644 --- a/crews/content-producer/skills/manim-explainer/SKILL.md +++ b/crews/content-producer/skills/manim-explainer/SKILL.md @@ -29,8 +29,8 @@ Use Manim for technical explainers where motion, structure, and clarity matter m - `manim` CLI for scene rendering - `ffmpeg` for post-processing if needed -- `fragment-assembly` for combining rendered video with TTS audio -- `siliconflow-tts` for voiceover generation +- `video-edit assemble` for combining rendered video with TTS audio +- `awk-tts` for voiceover generation ## Default Output @@ -74,13 +74,13 @@ For social-graph and network-optimization explainers: ```bash # 冒烟测试(低质量,优先用此验证构图) -./skills/manim-explainer/scripts/render-manim.sh .py low ./output +manim-explainer .py low ./output # 中等质量预览 -./skills/manim-explainer/scripts/render-manim.sh .py medium ./output +manim-explainer .py medium ./output # 正式输出(高质量) -./skills/manim-explainer/scripts/render-manim.sh .py high ./output +manim-explainer .py high ./output ``` 脚本自动完成:渲染 → 定位 MP4 → 导出第 2 秒封面帧,最后输出 JSON: @@ -95,7 +95,7 @@ Use [assets/network_graph_scene.py](assets/network_graph_scene.py) as a starting Example smoke test: ```bash -./skills/manim-explainer/scripts/render-manim.sh assets/network_graph_scene.py NetworkGraphExplainer low ./output +manim-explainer assets/network_graph_scene.py NetworkGraphExplainer low ./output ``` ## Output Format @@ -110,6 +110,6 @@ Return: ## Related Skills -- `fragment-assembly` for combining rendered video with TTS audio -- `siliconflow-tts` for voiceover generation +- `video-edit assemble` for combining rendered video with TTS audio +- `awk-tts` for voiceover generation - `content-check` for verifying output quality and duration diff --git a/crews/content-producer/skills/siliconflow-tts/SKILL.md b/crews/content-producer/skills/siliconflow-tts/SKILL.md deleted file mode 100644 index 1e09e230..00000000 --- a/crews/content-producer/skills/siliconflow-tts/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: siliconflow-tts -description: Generate speech audio via SiliconFlow Text-to-Speech API. Converts text to MP3/WAV/Opus/PCM using fnlp/MOSS-TTSD-v0.5 voices and SILICONFLOW_API_KEY. -metadata: - openclaw: - emoji: 🔊 - requires: - bins: - - python3 - env: - - SILICONFLOW_API_KEY - primaryEnv: SILICONFLOW_API_KEY - homepage: https://docs.siliconflow.cn/cn/api-reference/audio/create-speech ---- - -# SiliconFlow TTS - -Generate narration audio from text using SiliconFlow Text-to-Speech API. - -Use this skill when: -- You need voiceover or narration audio for a video -- You need standalone TTS assets before composing with Remotion/MoviePy -- You want to convert a script into reusable `.mp3`, `.wav`, `.opus`, or `.pcm` - -## Run - -**Do NOT set env vars inline** (for example, `SILICONFLOW_API_KEY=... python3 ...`). The env var is already in the system environment; inline assignments break the exec permission check. - -```bash -# Basic Chinese narration, saved under ./tmp/sf-tts-/speech.mp3 -python3 ./skills/siliconflow-tts/scripts/tts.py --text "大家好,欢迎来到今天的视频。" - -# Read text from a file -python3 ./skills/siliconflow-tts/scripts/tts.py \ - --text-file ./scripts/script.txt \ - --out-dir ./assets/audio - -# Fragment workflow: read tts_requirement.md, extract voiceover/voice/speed, -# and output speech.mp3 + speech.json to ./fragments/01-hook/artifacts/ -python3 ./skills/siliconflow-tts/scripts/tts.py ./fragments/01-hook/ --overwrite - -# Select voice, format, and exact output path -python3 ./skills/siliconflow-tts/scripts/tts.py \ - --text "This is a demo voiceover." \ - --voice "fnlp/MOSS-TTSD-v0.5:benjamin" \ - --format wav \ - --sample-rate 44100 \ - --output ./assets/audio/demo.wav -``` - -## Parameters - -| Flag | Default | Description | -|------|---------|-------------| -| `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`, or `fragments` | -| `--model` | `fnlp/MOSS-TTSD-v0.5` | SiliconFlow TTS model | -| `--voice` | `fnlp/MOSS-TTSD-v0.5:benjamin` | Voice ID | -| `--format` | `mp3` | Audio format: `mp3`, `opus`, `wav`, `pcm` | -| `--max-tokens` | — | Optional maximum output tokens | -| `--sample-rate` | — | Optional sample rate. `mp3`: 32000/44100; `opus`: 48000; `wav`/`pcm`: 8000/16000/24000/32000/44100 | -| `--stream` / `--no-stream` | `--no-stream` | Request streaming or non-streaming response | -| `--speed` | — | Optional speech speed, range `0.25`–`4.0` | -| `--gain` | — | Optional audio gain, range `-10`–`10` | -| `--output` | — | Exact output file path under `assets/audio`, `tmp`, `output_videos`, or `fragments` | -| `--out-dir` | `./tmp/sf-tts-` | Output directory under `assets/audio`, `tmp`, `output_videos`, or `fragments` when `--output` is not set | -| `--overwrite` | off | Overwrite existing output audio/metadata files | -| `--no-asr-check` | off | Skip ASR self-check after TTS generation | - -## Recommended voices - -| Voice ID | Notes | -|----------|-------| -| `fnlp/MOSS-TTSD-v0.5:benjamin` | 幽默男声,语速较慢,推荐 | -| `fnlp/MOSS-TTSD-v0.5:charles` | 激昂男声,适合广告 | -| `fnlp/MOSS-TTSD-v0.5:claire` | 清澈女声,推荐 | -| `fnlp/MOSS-TTSD-v0.5:david` | 清脆男声 | -| `fnlp/MOSS-TTSD-v0.5:diana` | 可爱女声,娃娃音 | - -## Dialogue format - -`fnlp/MOSS-TTSD-v0.5` supports spoken dialogue scripts. Use speaker tags when writing multi-speaker dialogue: - -```text -[S1]Hello, how are you today?[S2]I'm doing great, thanks for asking! -``` - -## Output - -- Audio file: `speech.` or the path set by `--output` -- Metadata file: `speech.json` beside the audio file, containing: - - `duration`: audio duration in seconds (via ffprobe) - - `model`, `voice`, `format`, `text_chars`, `audio_bytes`, `file` etc. - -When used in the content-producer fragment workflow, pass the fragment directory directly. The script reads `tts_requirement.md`, extracts the `## 配音文案` / `## Voiceover Text` section, reads voice/speed settings, and writes directly to the fragment's `artifacts/` directory. - -For `tts_requirement.md`, the script skips markdown headings, comments, and voice settings when synthesizing audio. - -## ASR Self-Check - -After generating audio, the script automatically runs an ASR self-check (unless `--no-asr-check` is set): - -1. Transcribes the generated audio via SiliconFlow ASR (`TeleAI/TeleSpeechASR` by default) -2. Compares transcription with the input text using Jaccard similarity -3. Threshold: **0.5** (50%) — based on testing, 50% Jaccard is sufficient for practical quality; higher thresholds caused excessive false negatives -4. Result printed as `PASS` or `WARN`; does not abort on failure - -The ASR check calls `/audio/transcriptions` with multipart form fields `file` and `model`, matching SiliconFlow's transcription API. It requires `SILICONFLOW_API_KEY`; if not set, the check is silently skipped. - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `SILICONFLOW_API_KEY` | Your SiliconFlow API key (required) | -| `SILICONFLOW_API_BASE` | Optional API base override, default `https://api.siliconflow.cn/v1` | -| `SILICONFLOW_ASR_MODEL` | Optional ASR model override, default `TeleAI/TeleSpeechASR` | diff --git a/crews/content-producer/skills/siliconflow-video-gen/SKILL.md b/crews/content-producer/skills/siliconflow-video-gen/SKILL.md deleted file mode 100644 index a6bf7f68..00000000 --- a/crews/content-producer/skills/siliconflow-video-gen/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: siliconflow-video-gen -description: Generate videos via SiliconFlow Video API. Supports text-to-video (T2V) and image-to-video (I2V) using Wan2.2 models. Async: submit job → poll until done → download. -metadata: - openclaw: - emoji: "🎬" - requires: - bins: - - python3 - env: - - SILICONFLOW_API_KEY - primaryEnv: SILICONFLOW_API_KEY - homepage: https://docs.siliconflow.cn/cn/userguide/capabilities/video ---- - -# SiliconFlow Video Gen - -Generate videos using the SiliconFlow Video API (Wan2.2 models). - -Video generation is **asynchronous**: the API returns a `requestId` immediately, then the script polls the status endpoint until the job completes (status: `Succeed`). - -> The generated video URL is valid for **1 hour**. The script downloads the video locally automatically. - -## Run - -Note: Video generation typically takes **1–5 minutes**. Set exec timeout accordingly (e.g., `exec timeout=600`). - -```bash -# Text-to-video -python3 ./skills/siliconflow-video-gen/scripts/gen.py --prompt "a dolphin leaping over ocean waves at sunset" - -# Image-to-video (provide a public URL or local base64 image) -python3 ./skills/siliconflow-video-gen/scripts/gen.py \ - --model "Wan-AI/Wan2.2-I2V-A14B" \ - --prompt "the camera slowly zooms out" \ - --image "https://example.com/my-photo.jpg" - -# Custom resolution and output directory -python3 ./skills/siliconflow-video-gen/scripts/gen.py \ - --prompt "time-lapse of a blooming flower" \ - --image-size 720x1280 \ - --out-dir ./out/videos - -# Reproducible generation with a fixed seed -python3 ./skills/siliconflow-video-gen/scripts/gen.py --prompt "rocket launch" --seed 42 -``` - -## Parameters - -| Flag | Default | Description | -|------|---------|-------------| -| `--prompt` | required | Text description of the video | -| `--model` | `Wan-AI/Wan2.2-T2V-A14B` | Model ID: `Wan-AI/Wan2.2-T2V-A14B` (T2V) or `Wan-AI/Wan2.2-I2V-A14B` (I2V) | -| `--image` | — | Image URL or `data:image/...;base64,...` (required for I2V model) | -| `--image-size` | `1280x720` | Resolution: `1280x720` (16:9), `720x1280` (9:16), `960x960` (1:1) | -| `--negative-prompt` | — | What to avoid in the video | -| `--seed` | — | Random seed for reproducibility | -| `--poll-interval` | `10` | Seconds between status polls | -| `--timeout` | `600` | Max seconds to wait for generation | -| `--out-dir` | `./tmp/sf-video-` | Output directory | - -## Models - -| Model | Type | Notes | -|-------|------|-------| -| `Wan-AI/Wan2.2-T2V-A14B` | Text → Video | Default model | -| `Wan-AI/Wan2.2-I2V-A14B` | Image → Video | Requires `--image` parameter | - -## Output - -- `video_.mp4` downloaded locally -- `result.json` with full API response - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `SILICONFLOW_API_KEY` | Your SiliconFlow API key (required) | diff --git a/crews/content-producer/skills/siliconflow-video-gen/scripts/gen.py b/crews/content-producer/skills/siliconflow-video-gen/scripts/gen.py deleted file mode 100644 index c04943a5..00000000 --- a/crews/content-producer/skills/siliconflow-video-gen/scripts/gen.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""SiliconFlow video generation — stdlib only (no httpx/requests). - -Flow: - 1. POST /v1/video/submit → requestId - 2. Poll POST /v1/video/status every --poll-interval seconds - 3. When status == 'Succeed', download video to --out-dir -""" - -import argparse -import json -import os -import sys -import time -import urllib.request -import urllib.error -from pathlib import Path - -SUBMIT_URL = "https://api.siliconflow.cn/v1/video/submit" -STATUS_URL = "https://api.siliconflow.cn/v1/video/status" - -T2V_MODEL = "Wan-AI/Wan2.2-T2V-A14B" -I2V_MODEL = "Wan-AI/Wan2.2-I2V-A14B" -VALID_SIZES = {"1280x720", "720x1280", "960x960"} - - -def post_json(url, payload, api_key, timeout=60): - data = json.dumps(payload).encode() - 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=timeout) as resp: - return json.loads(resp.read()) - except urllib.error.HTTPError as e: - body = e.read().decode(errors="replace") - print(f"[error] HTTP {e.code}: {body}", file=sys.stderr) - sys.exit(1) - - -def submit_job(payload, api_key): - result = post_json(SUBMIT_URL, payload, api_key, timeout=60) - rid = result.get("requestId") - if not rid: - print(f"[error] No requestId in response: {result}", file=sys.stderr) - sys.exit(1) - return rid - - -def poll_until_done(request_id, api_key, poll_interval, timeout): - deadline = time.time() + timeout - attempt = 0 - while time.time() < deadline: - attempt += 1 - result = post_json(STATUS_URL, {"requestId": request_id}, api_key, timeout=30) - status = result.get("status", "") - print(f"[info] poll #{attempt}: status={status}") - if status == "Succeed": - return result - if status == "Failed": - reason = result.get("reason", "unknown") - print(f"[error] Generation failed: {reason}", file=sys.stderr) - sys.exit(1) - # InQueue or InProgress — wait and retry - time.sleep(poll_interval) - print(f"[error] Timed out after {timeout}s", file=sys.stderr) - sys.exit(1) - - -def download_video(url, dest_path): - """Stream-download the video file to dest_path.""" - print(f"[info] Downloading video → {dest_path}") - req = urllib.request.Request(url, headers={"User-Agent": "wiseflow-video-gen/1.0"}) - with urllib.request.urlopen(req, timeout=300) as resp: - dest_path.write_bytes(resp.read()) - - -def main(): - parser = argparse.ArgumentParser(description="SiliconFlow video generation") - parser.add_argument("--prompt", required=True, help="Video description") - parser.add_argument( - "--model", - default=T2V_MODEL, - choices=[T2V_MODEL, I2V_MODEL], - help="Model ID", - ) - parser.add_argument( - "--image", - default=None, - help="Image URL or base64 data URI (required for I2V model)", - ) - parser.add_argument( - "--image-size", - default="1280x720", - choices=sorted(VALID_SIZES), - dest="image_size", - help="Video resolution", - ) - parser.add_argument("--negative-prompt", default=None, dest="negative_prompt") - parser.add_argument("--seed", type=int, default=None) - parser.add_argument("--poll-interval", type=int, default=10, dest="poll_interval") - parser.add_argument("--timeout", type=int, default=600) - parser.add_argument("--out-dir", default=None, dest="out_dir") - args = parser.parse_args() - - if args.model == I2V_MODEL and not args.image: - print(f"[error] --image is required when using model '{I2V_MODEL}'", file=sys.stderr) - sys.exit(1) - - api_key = os.environ.get("SILICONFLOW_API_KEY") - if not api_key: - print("[error] SILICONFLOW_API_KEY not set", file=sys.stderr) - sys.exit(1) - - ts = int(time.time()) - out_dir = Path(args.out_dir) if args.out_dir else Path(f"./tmp/sf-video-{ts}") - out_dir.mkdir(parents=True, exist_ok=True) - - payload = { - "model": args.model, - "prompt": args.prompt, - "image_size": args.image_size, - } - if args.image: - payload["image"] = args.image - if args.negative_prompt: - payload["negative_prompt"] = args.negative_prompt - if args.seed is not None: - payload["seed"] = args.seed - - print(f"[info] Submitting job: model={args.model} size={args.image_size}") - request_id = submit_job(payload, api_key) - print(f"[info] Job submitted. requestId={request_id}") - print(f"[info] Polling every {args.poll_interval}s (timeout={args.timeout}s)…") - - result = poll_until_done(request_id, api_key, args.poll_interval, args.timeout) - - videos = result.get("results", {}).get("videos", []) - if not videos: - print(f"[error] No videos in result: {result}", file=sys.stderr) - sys.exit(1) - - video_url = videos[0].get("url", "") - if not video_url: - print("[error] Empty video URL in result", file=sys.stderr) - sys.exit(1) - - video_path = out_dir / f"video_{request_id[:8]}.mp4" - download_video(video_url, video_path) - - result_path = out_dir / "result.json" - result_path.write_text(json.dumps(result, ensure_ascii=False, indent=2)) - - print(f"[done] Video saved to: {video_path}") - print(f"[done] Metadata: {result_path}") - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/ui-demo/SKILL.md b/crews/content-producer/skills/ui-demo/SKILL.md deleted file mode 100644 index b7b09d43..00000000 --- a/crews/content-producer/skills/ui-demo/SKILL.md +++ /dev/null @@ -1,479 +0,0 @@ ---- -name: ui-demo -description: 录制精美的产品 UI demo 视频。当用户需要录制演示视频、功能演示、操作教程或利益相关方展示视频时使用。输出带可见鼠标、自然节奏和专业字幕的 - WebM 视频。 -metadata: - openclaw: - emoji: 🎥 - requires: - bins: - - node ---- - -# UI Demo Video Recorder - -使用 patchright `recordVideo` + 注入的鼠标覆盖层、字幕和自然节奏,录制精美的 Web 应用演示视频。 - -> **CDP 模式**:通过 patchright 连接 本机已安装 Chrome (不准自行安装无头浏览器),若本机未安装Chrome,则提示用户先进行安装,退出执行。 -> -> **Patchright 1.60+ 替代方案**:Screencast API(`page.screencast`)提供内置录制+动作标注+章节+自定义 overlay,可替代手动注入 cursor/subtitle。详见下方「Screencast API 方案」章节。 - -## When to Use - -- 用户需要"演示视频"、"产品录屏"、"功能演示"或"操作教程" -- 需要制作用于文档、用户引导或投资人/客户展示的视频 - -## Three-Phase Process - -**Discover → Rehearse → Record**。禁止跳过直接录制。 - ---- - -## Phase 1: Discover(browser tool) - -在写录制脚本之前,用 **browser tool** 逐一导航到流程中的每个页面,了解真实的页面结构。 - -**目标:建立每个页面的字段映射表**,用于 Phase 3 脚本中的选择器。 - -每个页面重点关注: - -- **表单字段类型**:是 ``、`