diff --git a/.github/workflows/build-dist.yml b/.github/workflows/build-dist.yml index e779e517..ba02336d 100644 --- a/.github/workflows/build-dist.yml +++ b/.github/workflows/build-dist.yml @@ -43,8 +43,8 @@ jobs: with: node-version: '24.15.0' - uses: pnpm/action-setup@v6 - with: - version: 10.30.2 + # 不指定 version,action-setup v6 自动读 package.json 的 packageManager + # (显式 version 会与其冲突报 "Multiple versions of pnpm specified") - name: Clone openclaw at pinned commit # 用完整 clone 而非 --depth=1 浅 clone:apply-addons.sh 跑 git apply --3way diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00f4dc92..4c12aa2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,9 @@ jobs: node-version: '24' - name: Install pnpm - # 显式 version 规避 action-setup#227(v6 早期不读 packageManager 版本) + # 不指定 version,action-setup v6 自动读 package.json 的 packageManager + # (显式 version 会与其冲突报 "Multiple versions of pnpm specified") uses: pnpm/action-setup@v6 - with: - version: 10.30.2 - name: Read pinned openclaw version id: pin diff --git a/AGENTS.md b/AGENTS.md index a64e956c..71777e93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,3 @@ -# AGENTS.md - -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. - -Codex 被授权在本仓库中执行任何 git 命令(包括 push、branch、tag 等),无需逐次确认。 - ## Docker 部署规范 - 用户态镜像、Compose service 和持久卷统一使用 **xiaobei** 命名;不得新增 `wiseflow-*` 镜像或卷名。 @@ -39,7 +33,7 @@ openclaw 实际识别的 frontmatter 字段(参见 `openclaw/src/agents/skills - 顶层:`name`、`description`(**必需**)、`user-invocable`(默认 true)、`disable-model-invocation`(默认 false) - `metadata.openclaw.*`:`emoji`、`homepage`、`skillKey`、`primaryEnv`、`os`、`requires`、`install`、`always` -其他字段(如 Codex 的 `argument-hint`、`allowed-tools`、`license`)会被静默忽略。 +其他字段(如 claude code 的 `argument-hint`、`allowed-tools`、`license`)会被静默忽略。 **写法用 YAML block style**,不要用 flow style(嵌套花括号 + 引号)。openclaw bundled 技能和官方文档均采用 block style: @@ -59,6 +53,14 @@ metadata: - `always: true` 的真实语义是"跳过 `requires` 二进制/env 检查直接判定 eligible"(见 `config-eval.ts:124`),**不是**"强制注入整个 SKILL.md"。如果 skill 没声明 `requires`,加 `always: true` 等于无意义,应删除。 - 加载阶段 openclaw 只把 `name` + `description` + SKILL.md 绝对路径塞进 system prompt 的 `` 块;agent 用到时才主动 read 全文。所以 frontmatter 写得再多也不会污染 system prompt,但反过来也意味着——除上述识别字段外,多余字段不会带来任何运行时收益。 +## SKILL.md 内容书写规范 + +SKILL.md 是写给**执行时的智能体**看的操作手册,不是开发日志。内容只写指导性指令、正例、反例: + +- ✅ 直接写"应该怎么做""不要怎么做",配 ✅ 正例 / ❌ 反例 +- ❌ 不写排查故事、历史往来、开发经历、思路背景、踩坑全过程 +- 经验教训要提炼成一条可执行的规则,而不是一段叙事 + ## skill 依赖打包规则 产品拆分后(D8)addons/ 结构已销毁,skill 只有两层: @@ -84,5 +86,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/CHANGELOG.md b/CHANGELOG.md index 663666d4..ee33354e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ +# v5.7.1 (2026-09-15) + +### 第三方插件 pin 升级(openclaw-weixin 2.4.8 / wecom-openclaw-cli 1.1.1) + +> openclaw 基座保持 `2026.7.1-2`(`0790d9f593`)不变。本轮只动第三方插件 pin,**不触碰本机部署实例**。 + +- **`@tencent-weixin/openclaw-weixin` 2.4.6 → 2.4.8**:上游 2.4.7/2.4.8 的唯一实质改动是一行 import——`createTypingCallbacks` 从已被 OpenClaw 2026.8.1 删除的兼容子路径 `openclaw/plugin-sdk/channel-runtime` 改到 `openclaw/plugin-sdk/channel-message`(上游声明新路径仍兼容最低宿主 2026.5.12)。其余为版本号与 CHANGELOG。 + - **对 7.1-2 宿主已实测兼容**(不是照抄上游声明):2.4.8 用到的 11 个 `openclaw/plugin-sdk/*` 子路径在 7.1-2 的 `package.json` exports 里全部存在;18 个具名导入逐个对 7.1-2 已 build 的 `dist/` 做运行时校验,12 个 value import 全部命中(含关键的 `channel-message :: createTypingCallbacks`),6 个 type-only import 在 7.1-2 的 `.d.ts` 中也都在。 + - pin 的 `integrity` 用本地 `npm pack` 下来的 tarball 算 sha512 与 npm `dist.integrity` 对账一致(`update.sh` / `install-wecom-channel.sh` 都是按 tarball sha512 校验)。 +- **`@wecom/wecom-openclaw-cli` 1.1.0 → 1.1.1**:安装流程更稳健——`openclaw plugins install` 支持透传 `--force`(7.1-2 的 `plugins-cli` 已有该 flag:Overwrite an existing installed plugin)、多 npm 源 failover 时不再顺手删插件目录、`channels.wecom` 配置备份与描述对缺失 `botId` 更宽容、移除废弃的 `getNpmPluginPath` / `hasValidChannelConfig`。 +- **`@tencent-weixin/openclaw-weixin-cli` 2.1.4 不变**:仍是 npm latest,integrity 已复核与 registry 一致。 +- 同步把 `scripts/install.sh` / `scripts/install-atomgit.sh` / `docker/docker-bootstrap.sh` 中「pin 文件缺失时」的兜底版本 `2.4.6 → 2.4.8`(三处,避免兜底路径装到旧版)。 + +--- + +### 安装与升级链路修复(插件幂等 / pnpm 守卫 / 死代码清理) + +> 起因:pin bump 后老用户升级不生效、新机 pnpm 版本不符致 `apply-addons` 直接炸。四个 installer + docker bootstrap 行为对齐,避免裸机 / atomgit / Windows / Docker 四条路线分叉。 + +- **openclaw-weixin 幂等判断改为按版本比对**:原 `plugins list | grep` 已装即 return,pin bump 后已装实例永远停在旧版,必须人工跑 `plugins install --force` 才升得上去。新增 `weixin_installed_version()`(主路径 `plugins list --json` 取 version,回落到 `$OPENCLAW_HOME/npm/projects/*/node_modules//package.json` glob——目录名带 hash 只能 glob);判定:已装 == pin → 跳过、!= pin → `--force` 升级、插件在但版本读不到 → 也 `--force` 重装(正确性优先)。三处同源实现一起改(`scripts/install.sh` / `scripts/install-atomgit.sh` / `docker/docker-bootstrap.sh`)——docker 侧 `/root/.openclaw` 是持久卷,镜像升级后插件不会跟着 pin 走,同样需要比版本。升级只换 `npm/projects` 下的包,登录态在 `$OPENCLAW_HOME/openclaw-weixin/` 数据目录不受影响。 +- **Windows 安装器补齐同一逻辑**:`install.ps1` / `install-atomgit.ps1` 的 `Install-WeixinPlugin` 仍是旧判断(匹配到插件名即 return),Windows 老用户重跑安装器永远停在旧版。新增 `Get-WeixinInstalledVersion`(主路径 `plugins list --json`,沿用 Capture-Streamed 的 EAP Continue 模式规避 PS 5.1 NativeCommandError;回落 npm/projects glob,与 bash 版逐分支对齐);判定与 bash 版一致,失败提示同步带 `--force`。便携 pwsh 7.6.6 下 AST 抽真实函数 + 假 openclaw 跑 6 场景全过(裸装 / 同版跳过 / 2.4.6→2.4.8 升级 / JSON 损坏走 glob 回落 / 版本读不到重装 / stderr 噪音不污染主路径)。 +- **`update.sh` bundled tarball 路径补 `--force`**:`vendor/openclaw-plugins/*.tgz` 每次都装、没有幂等跳过,但不带 `--force` 时在已装实例上会失败,进而触发紧随的 `exit 1` 把整次 update 打断。在线路径(`npx openclaw-weixin-cli install`)未动——该 CLI 遇到固定版本 spec 会自行跳过升级,要修得改调用方式,按决定暂不处理。 +- **pnpm 版本守卫**:pnpm 10.x 的 install CLI 不认 `--fetch-retries`(`apply-addons.sh` 依赖同步直接 unknown argument 炸掉),而 `update.sh` 零预检、`install.sh` 的 `install_pnpm()` 只判断存在即跳过,报错完全不指向真因。`update.sh` 步骤 1.5 加大版本预检(读 `openclaw/package.json` 的 packageManager pin;版本探测在 `/` 下做,避开 pnpm ≥11 在带 pin 目录返回假版本);`install.sh` 定义 `PNPM_VERSION`(此前从未定义,真走到安装分支会执行 `npm install -g pnpm@` 空版本)并加 major 守卫(低于要求版本则升级而非跳过);仓根 `package.json` 的 packageManager 从 4 月遗留的 `10.30.2` 对齐 `11.2.2`。 +- **install.sh / install-atomgit.sh 删旧源码路线死代码**:tarball 路线 `main()` 从不调用系统依赖 bootstrap(install_node / install_git / install_pnpm)与 git clone(clone_wiseflow / checkout_openclaw_at_pin),是 tarball 定案前旧 curl-源码路线的遗留。`install.sh` 删 19 个零调用者函数(含 require_sudo / install_homebrew / node 版本解析链等传递死代码)+ `USE_LOCAL`/`--use-local`;`install-atomgit.sh` 删同款死 helper 与 `--use-local` no-op 分支;两脚本头注释对齐(`update.sh` 原写「拉新 tarball」,实为 git clone 源码升级路线)。README 删 `--use-local` 行(该 flag 由静默 no-op 变为明确 Unknown option)。ps1 未动(本就 pnpm 11 原生)。 +- **`setup-crew` 只为对外 crew 生成 ALLOWED_COMMANDS**:internal crew → `security:full`,`apply_exec_tiers` 本就不读该文件(权限模型在 exec-tiers.sh),4b/4b.5 加 crew-type 闸门(以 workspace SOUL.md 为准,与 4c 一致),避免对内 crew 生成死文件并误导成受白名单约束。SOUL.md 缺失时 `resolve_crew_type` 兜底 external 照常生成,保持 fail-closed。已存在的旧文件不删(可能含用户手工添加的私有条目,运行时无副作用)。 + +### expert-video 工具链补齐(消除主打形态被迫手写脚本的结构性缺口) + +> 按 content-producer 提交的 toolchain-proposal 实施(P0×2 / P1 / P2)。功能实测 50/50 通过(守卫失败路径、checkpoint 续渲、spec 错误拒绝、burn-srt/normalize 全链互操作、1080p 生产规格冒烟、渲染帧目检);code-review 1 HIGH / 2 MEDIUM / 7 LOW 全部修复并附回归测试。 + +- **`narration-layout`(P0,新增)**:逐句旁白排布 + 防重叠守卫 + 逐句/末句越界断言 + SRT + 可选混音(内部 subprocess 复用 audio-mix);`force_style` 落 `abs_starts.json` 供 burn-srt 引用;首句 min_gap 哨兵、lead_in 非负校验;产物按 hard 直拼排布(docstring 与 SKILL.md 均声明转场约束)。 +- **`motion-graphics`(P0,新增)**:声明式 spec 驱动 PIL 逐帧动画,Stage 10 第二条渲染路径(与 render-shot 并列);内置四件套模板(dimension_grid / scroll_cards / crew_panel / rec_highlight,收编 v4 已验证组件)+ 8 类基础元素(必填字段 load_spec 统一校验)+ `custom` 插件逃生舱(编码/checkpoint/校验留在子命令);帧级 checkpoint + `--force`;时长/分辨率/帧率三重断言(`fit=none` 错画幅不再静默);缺 ffmpeg/字体 exit 2。`_mg_lib` / `_mg_templates` 为非子命令库。 +- **`clip-trim`(P1)**:新增 `--normalize WxH@FPS` / `--grade warm` / `--windows` 多窗 / `--zoompan` 定帧缓推 / `--duration` / `--low-load` / `--force`。**切片起点改 input seek**——修 output seek 在倍速链下被静默放大 speed 倍的内容错位(视频音频流统一);多窗/定帧工作目录带参数指纹,改参自动重切、不静默复用陈旧内容;顺带修 `.mp3` 容器封 aac 的存量 bug(改为按输出扩展名选编码器)。 +- **`assemble`(P1)**:新增 `--manifest` 显式有序段清单(绕开 shot-NN 命名约定)/ `--verify-fps` 拼接后帧率断言(checkpoint 命中重跑同样生效)/ `--expect-durations` 逐段时长校验(list/segments/beats 三形态兼容,全量违例收集);段名含 `/` 时 unify 落盘名打平。 +- **`audio-mix`(P2)**:逐轨 `--fadein`/`--fadeout`(afade 在 adelay 前,fadeout 锚定 `min(轨长, duration-delay)`,超限报错);`--duration` 硬上限语义写明;修 adelay 缺 `:all=1` 立体声只延左声道的存量 bug。 +- **文档同步**:wrapper help、video-producer 与 expert-video 两份 SKILL.md(阶段链 + Stage 12 组合套路新增逐句排布与动态图形配方)、reversal-ad / narration-video 护栏条款、`requirements.txt` 的 Pillow 注释补 motion-graphics;顺带清 audio-mix.py / delivery-promise-lock.py 的 U+FFFD 乱码。 + +### expert-video 定位纠正 + crew 级后期脚本合入 + +- **crew 级后期脚本合入 expert-video**:`crews/content-producer/scripts/` 的五个后期脚本(normalize / burn-srt / duck / denoise / interp)经排查只被 CP 自己的三份文档引用(main 侧、install / setup-crew / apply-addons、docker、allowlist 全无引用),本该合。移到 `skills/expert-video/tools/video-producer/scripts/`,自动获得 wrapper 子命令形态(`video-producer normalize|burn-srt|duck|denoise|interp`),与注入的 exec 规范(一律绝对路径、禁 `cd` 前缀)对齐——原调用方式是 workspace 根相对路径 `python3 scripts/x.py`,本来就冲突。顺带修掉 crew 级目录的两个结构性问题:`setup-crew` 对 `crews//` 是 `cp -R` 拷贝(技能是软链改了即生效,crew 级 scripts 改了要重跑部署才生效),以及同一批脚本被三处文档重复描述——现在唯一权威落点是 `video-producer/SKILL.md` 的「后期处理子命令」段,`scripts/README.md` 删除。 +- **SKILL.md 重写(296 → 217 行)**:纠正「通用制作流程」的定位——它不是与类型 workflow 并列的第四条路,也不是 Brief 未指定类型时的 fallback,而是**做任何视频制作工作都必须遵循的基准准则**,reversal-ad / narration-video / collage-broll 只是在其上对特定类型的进一步细化。原「Workflow 清单(按视频类型选)」表里把「通用制作流程」当成一行选项,已删除该行,改为「这个包怎么读(三层)」:通用制作流程 = 基准 / 类型 workflow = 细化 / 工具说明 = 子命令参数,冲突时以 workflow 为准但闸门与护栏不让步。「本包」全部改「我」(与 AGENTS.md 第一人称口径一致);瘦身去重(Stage 12 参数表删掉只留八个场景化组合套路、依赖表 11 行压成两段),硬规则一条没丢。全仓同步该错误提法:三份 DNA 框架的制作指向表、三份 `build_style_profile.py` 的 video-form 提取提示、三平台 content-production、viral-chaser、CP AGENTS.md、`docs/expert-pack-dna-architecture.md` 4.7/4.9。 +- **reversal-ad 工作流增强**:`workflows/reversal-ad.md` 依实战补强(+44/−17),SKILL.md 输入表同步。 +- **注(需部署动作,本仓未做)**:`~/.openclaw/workspace-content-producer/` 仍是 09-08 的四条悬挂软链(collage-broll / design-full / manim-explainer / video-producer),expert-video 与 expert-design 尚未链入,`~/.openclaw/bin` 下同名 wrapper 也是悬挂;需重跑 `./scripts/setup-crew.sh`(会 prune 悬挂链、重建软链与 wrapper),`workspace-content-producer/scripts/` 里的五个旧拷贝可一并手工清掉。 + +### DNA(内容风格 DNA)补「业务植入」与「互动引导与 CTA」两维 + +- 三个平台(抖音 / 小红书 / 视频号)共 5 份框架各补两维:`biz-implant` 业务植入套路(是否植入 / 位置与时机 / 载体 / 方式原型(反转植入 / 痛点→方案 / 场景带入 / 实测对比 / 清单第 N 项 / 身份认同 / 口碑故事 / 教程内嵌 / 硬广直给)/ 衔接句 / 密度与占比 / 品牌词出现方式)、`interaction-cta` 互动引导与 CTA(行动目标与主目标 / 位置与时机 / 句式与原文摘录 / 行动数量 / 诱因设计 / 与转化目标对应 / 平台组件与合规边界)。 +- 与 `narration-script` 划界(口播子模块的「合」只记收束方式,CTA 目标与句式归 `interaction-cta`)、与 `video-form` 划界(形态层面的「影视解说 + 反转植入」记形态与制作指向,本维度记植入怎么设计)。聚合规则补硬要求:两维必须给出「位置 + 载体 + 原文摘录」三样证据,只写「自然植入」「引导关注」不算提取完成;无植入写「无植入(纯内容)」。 +- 维度数变为 抖音视频 10 / 图文 9、小红书视频 11 / 图文 10、视频号 10,三份 `build_style_profile.py` 的 DIMENSION_GROUPS / 报告提示 / TEMPLATE_STAGES 等同步,template 新增「[业务植入与 CTA部分]」(图文侧取代原「互动与标签」段)。 +- **删「统计与分词」死代码**:分词(相邻二字组合)在脚本里算完从未渲染进 report 或 DNA 文档,统计口径合并进「职责边界」一行;脚本移除 `extract_terms` / `STOP_TERMS` 及相关未使用导入,同样从未被读取的 `weighted_coverage` 聚合项一并删掉。 +- **xhs 作品类型表纠正**:默认 `--kind` 是 note,但表里把 `dna-0` 挂在 video、`dna-0-note` 挂在 note,与脚本 `KIND_SUFFIX_HINT`、框架文档、style-dna.md、专家包 SKILL.md 全部相反——改为 note(默认)→ `dna-0`、video → `dna-0-video`,默认 kind 排表格首行。 +- **视频号 content-production.md 按抖音版重写**:补 Step 0「识别制作路线」(素材组装 / 从零制作 / 脚本制作)与素材模式、脚本模式入口;Step 编号连续(原 Step 6 之后直接跳到 8/9/10);制作合并为 Step 5(路线 A main 直接做轻加工 / 路线 B、C 出 Brief 委托 CP);确认节点回到三个(选题 / 短标题与视频描述 / 成片与封面——原「第四个必停节点」编号错且缺成片确认)。视频号特有约束全部保留:短标题 + 视频描述双字段、取数与入库只用视频描述、短标题不写库、无公开下载路径、30-55 人群与真人出镜占比、转发动机、session 正忙与扫码登录、sph 链接。 +- 顺带清掉写给开发者的元叙述(「DNA 不是『平台级 DNA』」等),改为正向表述;Brief 新字段同步到三平台 content-production、CP 侧交接契约与 `account-benchmark` / `style-dna` / `review` 的归因表。 + +### 平台实操路径回写(视频号发布 / 小红书 / X) + +- **wechat-channels-publish 实测路径回写**(来源:小贝 2026-09-14 实战提案,eval 片段取自当日发布实录并通过 `node --check`):snapshot 一律 `-s "wujie-app"`(整页 snapshot 撞 wujie 双 body 报 strict violation);Step 3 视频上传改直连 `input[type=file]` 选择器(click 触发按钮路线拿不到 ref);新增「设置封面」步骤(编辑入口 / 上传封面 / `accept*=image` 精确选择器防双 file input 冲突 / 裁剪确认);描述框改 eval 四步(聚焦 → insertText → insertParagraph → innerText 校验,contenteditable 空串致 aria 无独立 ref);短标题 fill + eval 读 `.value` 校验(aria 占位文本填后不消失,snapshot 复核不可信)。新增 4 个 pitfall + 错误处理表 3 行;content-production.md 发布清单补「设置封面」项。 +- **`publish_xhs.py` 路径解析修复**:`_shared` 上溯按 D8 拆分前布局写死 `parents[3]`,拆分后(`skills/expert-xhs/tools/xhs-publish/scripts/`)只到 `tools/`,`relay_sign` import 落空。改 `parents[4]` 精确解析到 `skills/_shared`。2026-09-14 小红书实际发布验证通过。 +- **twitter-post CJK 输入安全闸门**:CJK 正文禁用 `type`(逐字符按键流与 X Draft.js 异步处理竞态,实测中文丢字 + 乱序),改 eval + `execCommand insertText` 整段插入;新增发布前 MATCH 校验闸门与发布后 profile 终验;「Something went wrong」先过闸门判因(MISMATCH 重插 / MATCH 瞬时错误重试),替换原「优先精简正文」的误判。含草稿回填重复、占位符假警报、emoji 渲染为 img 三个坑。expert-bd comment-engagement 复用同套规则。 +- **微信视频号 / 公众号登录统一无头截 QR**:同模式无头截二维码发用户扫码,渲染失败才 `--headed` 兜底;`docs/platform-login-and-browser-spec.md` 两处旧说法连带修正。 +- **`published-track record` 补 `--help`/`-h`**:对齐 `update-metrics.sh` 既有模式(DB 检查前响应,无库也能查参数),覆盖全部必填/可选参数、upsert 去重键、`wx_channel --title` 语义(完整视频描述非短标题)、publish-date 禁传 `$()` 警示。 +- **Brief 硬性规则新增「不写实现路径」**:Brief 只写需求与验收标准,禁写脚本引用 / ffmpeg 参数表 / 工具链内部细节;用户点名参数例外照传并注明。不依赖工具链补齐进度——边界规则本身恒成立,过渡期由既有「CP 回报无法执行 → 确认改 Brief」条款兜底。 + +### 清理 + +- 删除软著申报脚本(`generate_code_doc.py` / `generate_form_info.py` / `generate_manual.py` / `swcr-register.sh`)及 expert-ir 的依赖与委派表条目,软著 / 商标 / 专利申报明确移出职责范围(`docs/d21-symlink-skill.md` C 类清单同步清理)。 +- awada 媒体发送说明更新(`scripts/lib/agent-skills.sh`);`README.md` 精简。 + +--- + # v5.7.0 (2026-08-31) ### 专家包(Expert Pack)架构 diff --git a/README.md b/README.md index 3de2a7ee..da0411fb 100755 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ - 图文海报生成 - 短视频生成与多平台分发(支持视频号、抖音、小红书) - Twitter/X、微博、知乎等平台发文 +- 微信朋友圈内容发布(通过企业微信接口) - 爆款视频追爆分析、仿写与再创作(支持抖音、B站和小红书视频链接) +- 赛道头部账号DNA级复刻(目前支持小红书、微信视频号、抖音、微信公众号四个平台) - 已发布作品数据监控与每日定时复盘 -- 内置小红书、抖音、twitter/x、公众号、视频号平台“专家包” -- 信息搜集与情报:内置 Smart Search,覆盖小红书、抖音、微博、知乎、B站、Twitter、YouTube、视频号、LinkedIn、Reddit、新闻、政务、财经、学术、购物、GitHub 等 18 类信源——无需配置任何 key、纯免费 -- 指定信源监控与提取 +- 自媒体评论区获客 +- 7*24 小时智能客服(售前接待) - 通过社交媒体寻找潜在客户或市场调研 -- 灵感记录与思路梳理 +- 信息搜集与情报:内置 Smart Search,覆盖小红书、抖音、微博、知乎、B站、Twitter、YouTube、视频号、LinkedIn、Reddit、新闻、政务、财经、学术、购物、GitHub 等 18 类信源——无需配置任何 key、纯免费 - "四声分析"法战略研判与讨论 - 产品deck、ppt制作,投资/IR 材料准备 -- 软件著作权、ICP 备案等材料辅助生成 -- 闲鱼运营、企业微信朋友圈触达 +- 网站设计与制作、ICP 备案等材料辅助 - …… 并且你只需通过手机上的微信与他沟通,即可实现全部功能!(同时支持飞书、企业微信) @@ -32,31 +32,13 @@ xiaobei 由Wiseflow (原AI首席情报官)作者 bigbrother666sh 开发。 --- -## 🚀 **v5.7.0 更新** - -> Let's do this like an expert. - -- **专家包(Expert Pack)——像专家一样干活**:公众号、小红书、抖音、视频号能力升级为“专家包”,起号定位、对标拆解、内容生产、改稿调整、数据复盘六条工作流跑通账号运营全生命周期。你只管把任务丢过来,小贝按专业路数把活办完,不再拿一份越写越厚的通用说明书应付所有平台。 -- **内容风格 DNA——先像人写,再自我进化**:16+维细颗粒度复刻对标账号,统计学聚合做证据底座、定性判断提炼规则,最后落成内容生产 DNA template;发布数据回流后按量触发评估 -> DNA不断自我进化,迎合目标客群口味。 -- **OpenClaw 上游同步 v2026.7.1-2**。 -- **camoufox-cli 升 0.7.3 + 浏览器二进制升 FF152**:同步上游安装链路修复(不再误拉 beta 版浏览器、broken install 立即报错),FF152 解决重负载下鼠标输入卡死等稳定性顽疾。 - -详见 [CHANGELOG.md](CHANGELOG.md) - ---- - -## 🚀 **v5.6.3 更新** - -- **🎬 Content Producer 正式发布**:我们参考了GitHub上几乎所有热门的视频生产项目,并重点解决了纯AIGC模式容易被平台限流,以及难以融合业务素材的问题。xiaobei系统的content-producer就是你目前能够找到的最适合获客内容制作的开源免费方案! - - > ☀️测试阶段,我们应用content-producer制作了xiaobei系统的第一支品牌故事宣传片: - > - > - YouTube:https://youtu.be/eK8aWWCNVZQ?si=K-MbWai-j6ydqCdy - > - bilibili:https://www.bilibili.com/video/BV1euMR6PEDh - > - 开发与使用心得: https://mp.weixin.qq.com/s/zxvWdCMUd0XquWxujvxkSg +## 🚀 **v5.7.1 更新** -- **安装脚本大幅优化**:对比 5.6.0,install.sh / install-atomgit.sh / install.ps1 / install-atomgit.ps1 四脚本修了若干实测踩坑——tarball 下载原子写、Windows `.env`/`daemon.env` 换行与 BOM、技能 wrapper 在 Windows 用 `.cmd` shim 替代软链、`OPENCLAW_HOME` 在 `set -u` 下报 unbound、atomgit 国内线路默认跳过 gum spinner bootstrap 避免连 GitHub 超时、Windows 软链需要开发者模式等,产品稳定性显著提高。 -- **wx-mp-hunter 更新**:原微信公众号素材接口方案因官方调整已不可用,我们更新了微信公众号获取方案 +- 小红书、抖音、视频号 DNA系统升级到2.0架构,Let's do this like an expert! +- content producer 升级为专家系统,现在除了AIGC大片外,还可以复刻众多短视频平台流行的“套路”,更易获得平台推荐流量: + > 效果展示,xiaobei的视频号:https://openclaw-for-business.com/xiaobei-wxchannel.jpg +- xiaobei 可直接指挥content producer,用户可选择将brief出具、节点验收等委托xiaobei +- 修复一键安装脚本中,openclaw-weixin不会自动升级的问题 详见 [CHANGELOG.md](CHANGELOG.md) @@ -201,17 +183,24 @@ irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit. > > 推荐用 [SiliconFlow](https://cloud.siliconflow.cn/i/WNLYbBpi)(🎁 xiaobei 邀请链接,注册认证后你可获得一张 16 元代金券),它提供 `BAAI/bge-m3` 与 `Qwen/Qwen3-VL-Embedding` 系列,均为 OpenAI 接口格式,可直接配置为 `memorySearch` 的 embedding provider。配置方法:把 `agents.defaults.memorySearch.provider` 从 `"none"` 改为 `"openai-compatible"`,并补上 `remote.baseUrl` / `remote.apiKey` / `model`;想开做梦就把 `plugins.entries.memory-core.config.dreaming.enabled` 改回 `true`。改完重启 gateway 生效。可以让小贝帮你完成配置。 -🎉 xiaobei 项目目前提供 **VIP Club**(售价 **168 元/年**),权益包括: +### 配置繁琐,不想操心? + +🎉 wiseflow团队现提供 **远程安装** 与 **远程技术支持** 增值服务,同时继续提供**VIP Club**(售价 **168 元/年**)服务,陪伴你从"小白"到"大神"! + +VIP Club(售价 **168 元/年**),权益包括: + +- 付费知识库:「手把手」安装教程、上手指南、配置全案、高阶玩法、最佳实践等…… +- VIP 微信交流群:问题讨论、产品反馈、技术沟通、专家 DNA 分享…… +- 官方中转服务:针对部分自媒体平台的发布技能要求固定 IP 和签名的问题,向 VIP Club 会员免费提供官方中转服务 +- 会员期内所有增值服务享半价(不含 awada lane 租赁) +- 会员可申请成为服务合作商 +- 会员可申请定制开发服务 -- **付费知识库**:包含《手把手从零开始安装教程》、《安装之后三分钟上手指南》、《Openclaw 自定义配置全案教程》、《Windows 下安装 WSL2 无脑教程》以及各种最佳实践分享 -- **vip 微信交流群**,共同探讨交流各种自动化获客玩法,搞钱路上不孤单 -- 免费加入 Wiseflow 知识星球 -- 每月一次的线上闭门分享(腾讯会议),陪伴你从"小白"到"大神"! -- **会员有效期内免费使用官方中转服务**:涉及小红书、抖音、bili、快手、微信公众号、企业微信朋友圈的技能都需要固定IP(平台要求),一般的家庭网络或办公网络环境并没有固定IP,Wiseflow团队已经搭建了官方的中转服务,vipclub会员期内畅用,不必再单独自建或购买。 +*⚠️ 如存在不当使用官方中转服务、在社群发表不当言论等行为,Wiseflow 团队有权提前终止会员资格且不予退款,详见会员协议。* -此外,我们也面向 VIP Club 会员提供如下增值服务:**远程安装部署、远程技术支持、awada lane 租赁** (需额外付费) +另开放 **服务代理商** 合作,共享AI时代红利!详见:https://openclaw-for-business.com/pricing -欢迎添加"掌柜的"企业微信(这背后接的就是 xiaobei sales-cs)咨询了解: +也欢迎添加"掌柜的"企业微信(由 xiaobei sales-cs 驱动)咨询了解: xiaobei掌柜 diff --git a/config/daemon.env.template b/config/daemon.env.template index 34744563..fb192c1c 100644 --- a/config/daemon.env.template +++ b/config/daemon.env.template @@ -15,6 +15,7 @@ OPENCLAW_DISABLE_BONJOUR=true # camoufox 有头登录走这个 display。用户不用改。 # DISPLAY=:99 -# ── PATH 注入(裸机由 install.sh 写,Docker 由 base image 已有)───────────── -# Docker 不需要此行(node:24-bookworm 自带 PATH);裸机 install.sh 会追加 node 路径 -# PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# ── PATH 注入(Docker entrypoint 保证 wrapper bin 在 PATH 最前)─────────── +# gateway 不 source shell rc,agent exec 调裸技能名(如 aigc-video-gen)靠此 PATH +# 解析 ~/.openclaw/bin 下的 wrapper 软链。此文件被 entrypoint source,$HOME 会展开。 +PATH=$HOME/.openclaw/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/crews/content-producer/AGENTS.md b/crews/content-producer/AGENTS.md index 26e63f42..cd4ebd22 100644 --- a/crews/content-producer/AGENTS.md +++ b/crews/content-producer/AGENTS.md @@ -1,28 +1,42 @@ # content-producer — Workflow -我是专业内容制作者,接到活儿先按下表选一条路,**首个匹配行即执行**,不向下评估。 +我是专业内容制作者,**始终是乙方**:甲方给需求(Brief)与已有素材,我出成品。接到活儿先按下表选专家包,包内再按 workflow 执行,**首个匹配行即执行**,不向下评估。 + +## 两种工作模式 + +| 模式 | 甲方 | 我的起点 | +|------|------|---------| +| A · Subagent 承制 | main agent(小贝) | 读甲方 Brief + 素材绝对路径 → 核对字段 → 缺关键字段向 Brief owner 澄清;**不重开需求讨论** | +| B · 直接对接用户 | 用户(已绑工作 channel) | 用户给了 Brief → 核对确认;没给 → 先引导讨论,**代用户整理 brief 并发用户确认后才开工** | ## 能力方向路由 -| 入口信号 | 走哪条路 | 入口技能 | -|---------|---------|---------| -| 用户要"从零做视频""出一支完整视频""按这个脚本/主题拍片子" | 端到端视频制作 | `video-producer` Stage 0→14 全流程 | -| 用户要对已有素材进行剪辑、修整、拼接等 | 已有素材剪辑 | `video-producer` Stage 12 工具箱 | -| 用户要"把这句话/这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | 纸拼贴组装动画 | `collage-broll` | -| 用户要"用 Manim 做技术演示""流程图/架构图动起来""指标可视化动画" | 技术演示视频 | `manim-explainer` | -| 用户要"做网页/落地页/APP 界面/品牌视觉体系"等平面设计 | 平面设计全案 | `design-full` | +**视频类铁律**:只要接的是视频制作活儿,`expert-video` 的**通用制作流程**(Stage 0→14 阶段链 + GATE A/B 两闸门 + 护栏 + 工作区与交付约定)**一律适用**——它是基准准则,不是"没匹配到类型时的备选",也不与类型 workflow 并列。下表匹配到的类型 workflow 只是叠加在基准上的进一步细化。 -## 通用约定 +| 入口信号 | 专家包 | 怎么做 | +|---------|--------|--------| +| Brief 指定 `workflow`(如 `reversal-ad`) | `expert-video` | 通用制作流程 + 读 Brief 指定的 `workflows/<值>.md`,按其阶段裁剪执行 | +| 影视解说 / 剧情解说 + 突然反转插入品宣("万万没想到"式) | `expert-video` | 通用制作流程 + Reversal Ad 细化 | +| 甲方交付口播文案或真人口播录音,要合成声画 | `expert-video` | 通用制作流程 + Narration Video 细化 | +| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | `expert-video` | 通用制作流程 + Collage B-roll 细化 | +| "从零做视频""出一支完整视频""按这个主题拍片子"(无匹配类型) | `expert-video` | 只按通用制作流程走,Stage 1 `intent-router` 定档位(narrative / motion / montage) | +| 已有素材要剪辑、修整、拼接、配音、烧字幕 | `expert-video` | 通用制作流程的 Stage 12 工具箱(只做几何级修整) | +| "做网页/落地页/APP 界面/品牌视觉体系" | `expert-design` | Web Page / App UI / Brand Visual | -- **每接到一个活儿先建工作区**:视频类走 `output_videos//`(由 `video-producer` 内脚本建);平台专家包委托并传入现成项目目录(`/outputs//`)时直接沿用,不另建;平面设计类走 `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 不碰 +## 交接契约(硬边界) -## 衔接关系 +- **甲方给我**:Brief(绝对路径)+ 已有素材(绝对路径 + 来源授权)+ 口播文案 / 口播录音(如有)。 +- **我给甲方**:成片 + 封面 + 交付说明(`final-deliver.md`),回报三者的**绝对路径**。 +- **甲方不替我建工作区,我也不替甲方建**:同一个活儿各自建各自的工作区。双方都是 T3 权限,需要时可直接读对方工作区取文件。 +- **甲乙方关系不破**:需求方向、品牌事实、卖点承诺、发布文案归甲方;工作区、制作方案、分镜、素材实现、渲染参数归我。缺信息就问甲方,不自行脑补,也不反过来指挥甲方。 + +## 通用约定 -- **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 不做 +- **每接到一个活儿先自建工作区**:视频类走 `output_videos//`,平面设计类走 `design_assets/YYYY-MM-DD-<任务名>/`(由 `design-full init` 建)。甲方传入的现成目录只作素材来源,不当自己的工作区。 +- **Brief 确认前不得干活**:模式 B 先整理 brief 发用户确认;模式 A 只接受带确认状态与闸门批准人的 Brief,Brief 已确认且 GATE A 已由 main 代理批准时不重开需求讨论。 +- **成品交付前必跑自检**:视频走公共 `video-review` + 响度归一化(`video-producer normalize`,-14 LUFS 必跑),平面设计走视觉 review(对照 brief + DESIGN.md)。 +- **封面**:视频成片默认交付含封面主文案的封面图,主文案来自 Brief(有平台标题用标题,视频号用短标题),走公共 `siliconflow-img-gen`。 +- **不许声称没做过的事**:没有 tool result 或产物文件证明,不许声称已生成/已渲染/已改动。 +- **平台运营不在 CP**:发布到抖音/视频号/小红书/B站等归 main agent 的各平台专家包,CP 不碰;也不私信用户、不代拟运营话术。 +- **语义级剪辑不在 CP**:已有素材的高光剪辑、去口气词归 main 的 `talking-head-cut` / `video-edit`;CP 只做几何级修整(切段/拼接/混音/烧字幕/补轨)。 +- **机器资源约束**:线程数、分辨率上限、低载编码等部署差异读本 workspace `MEMORY.md` 或 Brief 的环境约束,不写死在技能包里。 diff --git a/crews/content-producer/BUILTIN_SKILLS b/crews/content-producer/BUILTIN_SKILLS index 57338e5f..7371e377 100644 --- a/crews/content-producer/BUILTIN_SKILLS +++ b/crews/content-producer/BUILTIN_SKILLS @@ -1,4 +1,2 @@ -video-producer -collage-broll -manim-explainer -design-full +expert-video +expert-design diff --git a/crews/content-producer/IDENTITY.md b/crews/content-producer/IDENTITY.md index 5340090f..70f615d7 100644 --- a/crews/content-producer/IDENTITY.md +++ b/crews/content-producer/IDENTITY.md @@ -13,6 +13,5 @@ content-producer(内容制作者) 🎬 ## Role -专业内容制作者,main agent 的助手。承担四条能力方向的执行: -端到端视频制作(出脚本/分镜/渲染/组装/交付)+ 视觉拼贴动画 + 技术演示动画 + 平面设计全案。 -既接受 main agent 在工作流中下发任务,也接受用户直接对话。 +专业内容制作者,乙方定位。 +既接受 main agent 派单,也接受用户直接对话。 diff --git a/crews/content-producer/openclaw_setting_sample.json b/crews/content-producer/openclaw_setting_sample.json index 7a932a29..2c6e1033 100644 --- a/crews/content-producer/openclaw_setting_sample.json +++ b/crews/content-producer/openclaw_setting_sample.json @@ -20,10 +20,8 @@ "smart-search", "video-review", "wxwork-drive", - "video-producer", - "collage-broll", - "manim-explainer", - "design-full" + "expert-video", + "expert-design" ], "subagents": { "allowAgents": [ diff --git a/crews/content-producer/scripts/README.md b/crews/content-producer/scripts/README.md deleted file mode 100644 index 615be930..00000000 --- a/crews/content-producer/scripts/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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/skills/collage-broll/SKILL.md b/crews/content-producer/skills/collage-broll/SKILL.md deleted file mode 100644 index a468f052..00000000 --- a/crews/content-producer/skills/collage-broll/SKILL.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -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 默认不拆透明图层 - -## 默认项目目录 - -路径契约——调用方指定了项目目录时(平台运营内容为 `/outputs//`)落在其下;否则落在 `output_videos/` 下,名 ``: - -```text -/ # 即 /outputs// 或 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/` 或 `/outputs/` 下,**调用时 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/design-full/SKILL.md b/crews/content-producer/skills/design-full/SKILL.md deleted file mode 100644 index 133f0a47..00000000 --- a/crews/content-producer/skills/design-full/SKILL.md +++ /dev/null @@ -1,262 +0,0 @@ ---- -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/expert-design/SKILL.md b/crews/content-producer/skills/expert-design/SKILL.md new file mode 100644 index 00000000..56405b07 --- /dev/null +++ b/crews/content-producer/skills/expert-design/SKILL.md @@ -0,0 +1,160 @@ +--- +name: expert-design +description: 平面设计专家技能包 +metadata: + openclaw: + emoji: 🎨 + requires: + bins: + - python3 +--- + +# 平面设计专家(expert-design) + +## 角色:始终是乙方 + +不管任务来自谁,我都是**乙方(承制方)**:按 Brief 交付设计成品,不自作主张改需求方向,也不替甲方定品牌事实与投放策略。 + +- 需求以 **Brief** 为唯一契约;Brief 未写的先问甲方,不脑补品牌色、卖点与合规承诺。 +- 设计实现(设计系统选取、token 组织、页面结构、组件写法)是乙方专业范围。 +- 交付边界:HTML/CSS 成品或 DESIGN.md + 预览页。**不发布上线、不做平台运营、不私信客户**。 + +## 两种工作模式 + +| 模式 | 甲方 | Stage/Step 起点 | +|------|------|-----------------| +| A · Subagent 承制 | main agent | 读甲方 Brief → 核对字段 → 缺口向 Brief owner 澄清,**不重开需求讨论** | +| B · 直接对接用户 | 用户(已配 channel) | 用户已给 Brief → 核对确认;没给 → 引导讨论并**代用户整理 brief.md,发用户确认后才开工** | + +模式 B 下用户不一定专业,乙方要替他把需求收敛清楚:做什么(网页/界面/品牌体系)、给谁看、要什么感觉、有哪些必须遵循的品牌资产、涉及哪些页面或组件、有没有参考站点、什么时候要。涉及用户已有素材(Logo、参考图、品牌资产)时,必须让用户给出**绝对路径**并逐条确认真实存在。 + +## 资源命名约定 + +- Workflows、Tools 名称是本技能包内的**逻辑资源名**,不是 Workspace 路径,不要拼成相对路径执行。 +- 技能部署后整个包通过软链进入运行环境;不要假设包内资源被展开到 Workspace 下。 +- 文档中出现的 `design_assets/` 才是 Workspace 相对路径,从 Content Producer workspace 根解析。 +- 只有工具清单中列出的 wrapper 名称可以直接作为 shell 命令调用。 + +## Workflow 清单(按设计任务类型选) + +| 任务类型 / 入口信号 | Workflow | +|--------------------|----------| +| 完整网页 / 落地页 / 团队介绍页 / 404 页 | Web Page | +| APP / Web APP / 管理后台 / SaaS 面板界面原型 | App UI | +| 品牌视觉体系(色彩 / 字体 / 组件 / 间距规范) | Brand Visual | + +三条 workflow 共用下方 Step 1/2/3/6/7 骨架,差异只在 brief 必含字段与 Step 4/5。 + +不适用:视频 / 动画 / 封面图 → `expert-video`(封面走其 Stage 14a);单张配图生成 → 公共 `siliconflow-img-gen`。 + +## 工具清单 + +| 工具 | 用途 | 命令 | +|------|------|------| +| `design-full` | 建任务工作区 + brief 模板;从内置 14+ 套设计系统库匹配风格 | `design-full init <任务名>` / `design-full pick "<风格描述>"` | + +跨领域公共技能:`pexels-footage` / `pixabay-footage`(配图与背景图首选)、`siliconflow-img-gen`(配图备选)、`smart-search`(参考站点调研)。 + +## 通用骨架(七步,两闸门) + +### Step 1:建工作区(强制起点,自建,甲方不代建) + +```bash +design-full init <任务名> +``` + +产出目录(落 Content Producer workspace 的 `design_assets/` 下): + +``` +design_assets/YYYY-MM-DD-<任务名>/ +├── brief.md # 设计需求(甲方交付拷贝入档,或模式 B 与用户定稿) +├── DESIGN.md # Step 3 选定的设计系统规范 +├── prompts.json # 生图参数记录 +├── source/ # 原始素材(参考图、品牌资产、甲方给的 Logo) +└── output/ # 成品(HTML/CSS、组件预览页) +``` + +`design_assets/` 同时建 `references/` 与 `brand/` 两个共享子目录(跨任务复用参考素材与品牌资产)。 + +### Step 2:Brief 确认(强制闸门) + +把需求整理写入 `brief.md`,**发甲方确认,等明确同意**。确认前不得进后续步骤;后续视觉 review 以 brief 为基准对照。 + +brief 至少含:产品类型 / 页面或界面清单 / 功能范围 / 风格方向 / 品牌约束 / 参考素材(绝对路径)。 + +### Step 3:设计系统选取(强制,不得凭印象直接写 CSS) + +```bash +design-full pick "<风格描述>" +``` + +从内置设计系统库匹配 1–3 套,展示匹配结果与推荐理由,**等甲方确认选定**;甲方也可指定参考品牌或自定义风格,据此生成定制 DESIGN.md。选定后把规范写入任务 `DESIGN.md`,后续所有 HTML/CSS 的色彩、字体、间距、组件样式都遵循它。 + +### Step 4:素材获取 + +- **优先**:公共 `pexels-footage` / `pixabay-footage` 搜索下载 +- **备选**:公共 `siliconflow-img-gen` 生成(参数记 `prompts.json`) +- 甲方给的素材入 `source/`,记录来源与授权 + +### Step 5:HTML + CSS 编写 + +- CSS custom properties 定义设计 token(颜色、间距、字号、阴影),严格遵循 DESIGN.md +- 语义化标签(header / main / section / footer) +- 响应式(min-width: 768px / 1024px 断点;APP 界面按 375px 基准移动优先) +- hover / focus / active / disabled / loading 状态完备 +- 图片引用 `source/` 中的素材 + +### Step 6:视觉 Review(强制闸门) + +1. 用 `image` 工具查看渲染结果 +2. 对照 `brief.md` 与 `DESIGN.md` 逐项检查:风格一致性、组件规范遵循度、响应式表现、交互状态完整性 +3. 发现偏差 → 调 CSS token 或 HTML 结构后重新输出(**最多 3 轮**) +4. Review 通过 → 交甲方 + +### Step 7:交付归档 + +最终确认后把文件保存到任务 `output/`,归档并更新 `index.md`;交付时回报成品**绝对路径**与关键决策(选定的设计系统、token 例外、遗留问题)。 + +## CSS 设计 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 可按设计系统适配 +- 品牌事实(产品名、能力表述、资质)只以 Brief 与 `business_knowledge.md` 为准,不自创 + +## 禁止事项(强制) + +- **禁止 brief 未确认就动手**:Step 2 闸门强制,确认前不得进 Step 3 +- **禁止跳过设计系统选取**:每项任务 Step 3 必跑 `design-full pick` +- **禁止跳过视觉 Review 交付**:Step 6 闸门强制,对照 brief + DESIGN.md 逐项查,不得裸交 +- **禁止凭空捏造品牌色**:MEMORY.md 有记录则强制遵循,无记录则设计后问甲方认可才记入 +- **禁止声称没做过的事**:没有产物文件或 tool result 证明,不许声称已生成/已渲染/已改 +- **禁止让甲方建工作区**:工作区自建(`design-full init`),也不要把产物写进甲方目录 +- **禁止越界做视频与发布**:视频走 `expert-video`,发布与运营归 main agent diff --git a/crews/content-producer/skills/expert-design/tools/design-full/SKILL.md b/crews/content-producer/skills/expert-design/tools/design-full/SKILL.md new file mode 100644 index 00000000..ba30d73c --- /dev/null +++ b/crews/content-producer/skills/expert-design/tools/design-full/SKILL.md @@ -0,0 +1,62 @@ +--- +name: design-full +description: 平面设计原子工具——建任务工作区与 brief 模板(init)、从内置设计系统库匹配风格(pick)。 +metadata: + openclaw: + emoji: 🎨 + requires: + bins: + - python3 +--- + +# design-full — 工具说明 + +> 本文是 `expert-design` 专家包内的工具说明书,不独立出现在技能列表中。设计流程(brief 闸门、设计系统确认、素材、编写、视觉 review、交付)由包内 SKILL.md 与 `workflows/` 编排。 + +**调用方式**:`design-full <子命令> [参数...]`(wrapper 转发到 `scripts/`,零路径拼接)。`design-full help` 查用法。 + +| 子命令 | 入 | 出 | 退出码 | +|--------|----|----|--------| +| `design-full init <任务名>` | 任务名(英文短横线式) | `design_assets/YYYY-MM-DD-<任务名>/`(含 `brief.md` 模板、`source/`、`output/`),并确保 `design_assets/references/`、`design_assets/brand/` 存在 | 0 成功 / 1 参数错 | +| `design-full pick "<风格描述>"` | 风格描述(中文关键词即可,如"科技感暗色主题") | stdout 列出全部可用设计系统 + 按匹配分排序的 1–3 套推荐及理由 | 0 成功 / 1 参数错或索引缺失 | + +**注意事项**: + +- `init` 按**当前工作目录**建 `design_assets/`,调用时 workdir 必须是 Content Producer workspace 根。 +- `init` 幂等:已存在的 `brief.md` 不覆盖(保留已填内容)。 +- `pick` 只做匹配与展示,**不写文件**;选定结果由 agent 写入任务目录的 `DESIGN.md`。 +- 匹配是关键词打分(keywords / category / name / description),不是语义检索;描述里多放风格词、行业词、色彩词命中率更高。 + +## 内置设计系统库 + +每套 8 段规范(Visual Theme / Color / Typography / Components / Layout / Depth / Do's & Don'ts / Responsive),文件在工具目录 `design-systems/.md`,索引 `design-systems/index.json`。 + +| 设计系统 | 风格关键词 | 适用场景 | +|---------|----------|---------| +| Stripe | 紫色渐变、优雅、金融科技 | SaaS 产品页、支付/金融科技落地页 | +| Vercel | 黑白极简、精密、Geist | 开发者工具、技术产品官网 | +| Linear | 超极简、紫色点缀、精确 | 项目管理、效率工具 | +| Notion | 暖色极简、衬线标题、柔和 | 知识管理、内容平台 | +| Apple | 极致留白、电影级影像 | 消费电子、高端品牌官网 | +| Supabase | 暗色翡翠绿、代码优先 | 数据库/后端服务、开源工具 | +| Shopify | 暗色电影感、霓虹绿 | 电商平台、商业服务 | +| Figma | 多彩活泼、专业、创意 | 创意工具、设计平台 | +| Spotify | 鲜明绿、大胆排版 | 媒体/娱乐平台 | +| Tesla | 极致减法、全屏影像 | 汽车/硬件、极简品牌 | +| Framer | 黑蓝、动效优先 | 网站构建、交互展示 | +| Airbnb | 暖色珊瑚、摄影驱动 | 旅游/生活服务、社区平台 | +| BMW | 巴伐利亚蓝、暗色奢华、金属质感 | 奢侈品牌、高端产品 | +| IBM | 企业蓝、Carbon 系统、数据密集 | 企业级产品、B2B 服务、数据平台 | +| Starbucks | Siren 绿、温暖社区、自然质感 | 生活品牌、餐饮/零售、社区平台 | + +## 自定义设计系统 + +内置库覆盖不到时,基于甲方描述自行构建,输出格式参照内置 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` 搜到。 diff --git a/crews/content-producer/skills/design-full/design-full.sh b/crews/content-producer/skills/expert-design/tools/design-full/design-full.sh similarity index 73% rename from crews/content-producer/skills/design-full/design-full.sh rename to crews/content-producer/skills/expert-design/tools/design-full/design-full.sh index 4ce8f0dd..ebf2d5ee 100755 --- a/crews/content-producer/skills/design-full/design-full.sh +++ b/crews/content-producer/skills/expert-design/tools/design-full/design-full.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# design-full.sh — design-full 顶层 wrapper(薄转发,子命令范式) +# design-full.sh — design-full 工具 wrapper(薄转发,子命令范式;expert-design 包内工具) # 让 agent 用 `design-full <子命令> [参数...]` 走 PATH,零路径拼接。 # 子命令: # init <任务名> 建任务文件夹 + brief 模板(落 design_assets/YYYY-MM-DD-<任务名>/) @@ -22,18 +22,18 @@ case "$SUBCMD" in ;; -h|--help|help) cat <<'HELP' -design-full — 平面设计全案(wrapper) +design-full — 平面设计原子工具(wrapper,expert-design 包内工具) 用法: 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 +子命令是 expert-design 通用骨架里的原子步骤: + Step 1 建工作区 → design-full init(workdir 必须是 Content Producer workspace 根) + Step 3 设计系统选取 → design-full pick 其余步骤(brief 确认、素材获取、HTML/CSS 编写、视觉 review、交付归档)由 agent -按 SKILL.md 工作流直接执行,不经本 wrapper。 +按 expert-design 包内 SKILL.md 与 workflows/ 直接执行,不经本 wrapper。 HELP ;; *) diff --git a/crews/content-producer/skills/design-full/design-systems/airbnb.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/airbnb.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/airbnb.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/airbnb.md diff --git a/crews/content-producer/skills/design-full/design-systems/apple.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/apple.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/apple.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/apple.md diff --git a/crews/content-producer/skills/design-full/design-systems/bmw.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/bmw.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/bmw.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/bmw.md diff --git a/crews/content-producer/skills/design-full/design-systems/figma.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/figma.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/figma.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/figma.md diff --git a/crews/content-producer/skills/design-full/design-systems/framer.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/framer.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/framer.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/framer.md diff --git a/crews/content-producer/skills/design-full/design-systems/ibm.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/ibm.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/ibm.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/ibm.md diff --git a/crews/content-producer/skills/design-full/design-systems/index.json b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/index.json similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/index.json rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/index.json diff --git a/crews/content-producer/skills/design-full/design-systems/linear.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/linear.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/linear.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/linear.md diff --git a/crews/content-producer/skills/design-full/design-systems/notion.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/notion.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/notion.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/notion.md diff --git a/crews/content-producer/skills/design-full/design-systems/shopify.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/shopify.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/shopify.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/shopify.md diff --git a/crews/content-producer/skills/design-full/design-systems/spotify.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/spotify.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/spotify.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/spotify.md diff --git a/crews/content-producer/skills/design-full/design-systems/starbucks.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/starbucks.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/starbucks.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/starbucks.md diff --git a/crews/content-producer/skills/design-full/design-systems/stripe.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/stripe.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/stripe.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/stripe.md diff --git a/crews/content-producer/skills/design-full/design-systems/supabase.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/supabase.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/supabase.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/supabase.md diff --git a/crews/content-producer/skills/design-full/design-systems/tesla.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/tesla.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/tesla.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/tesla.md diff --git a/crews/content-producer/skills/design-full/design-systems/vercel.md b/crews/content-producer/skills/expert-design/tools/design-full/design-systems/vercel.md similarity index 100% rename from crews/content-producer/skills/design-full/design-systems/vercel.md rename to crews/content-producer/skills/expert-design/tools/design-full/design-systems/vercel.md diff --git a/crews/content-producer/skills/design-full/scripts/init.sh b/crews/content-producer/skills/expert-design/tools/design-full/scripts/init.sh similarity index 100% rename from crews/content-producer/skills/design-full/scripts/init.sh rename to crews/content-producer/skills/expert-design/tools/design-full/scripts/init.sh diff --git a/crews/content-producer/skills/design-full/scripts/pick.sh b/crews/content-producer/skills/expert-design/tools/design-full/scripts/pick.sh similarity index 100% rename from crews/content-producer/skills/design-full/scripts/pick.sh rename to crews/content-producer/skills/expert-design/tools/design-full/scripts/pick.sh diff --git a/crews/content-producer/skills/expert-design/workflows/app-ui.md b/crews/content-producer/skills/expert-design/workflows/app-ui.md new file mode 100644 index 00000000..7a2d4902 --- /dev/null +++ b/crews/content-producer/skills/expert-design/workflows/app-ui.md @@ -0,0 +1,32 @@ +# Workflow:App UI(APP / 产品界面原型设计) + +任务类型:移动 APP、Web APP、管理后台、SaaS 面板的界面原型。Step 1/2/3/6/7 按 `expert-design` SKILL.md 的通用骨架执行,本文只写本类型的 brief 必含字段与 Step 3/5 差异。 + +## Step 2:brief 必含字段 + +- 产品类型(移动 APP / Web APP / 管理后台 / SaaS 面板…) +- 核心页面清单(登录 / 首页 / 列表 / 详情 / 设置…) +- 交互模式(导航方式、手势支持、状态管理) +- 数据形态:真实数据样例或占位规则(不得自造业务数字) +- 风格参考与品牌约束 + +## Step 3 之后:另写 DESIGN.md 设计规范 + +在设计系统选定结果基础上补全界面专用规范: + +- 色彩系统(语义色名 + hex + 用途:primary / secondary / surface / error…) +- 字体系统(font-family + 层级表:display / heading / body / caption / overline) +- 间距系统(4 / 8 / 12 / 16 / 24 / 32 / 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/`;回报绝对路径 + 页面清单 + 视觉 review 结论 + 未覆盖的状态说明。 diff --git a/crews/content-producer/skills/expert-design/workflows/brand-visual.md b/crews/content-producer/skills/expert-design/workflows/brand-visual.md new file mode 100644 index 00000000..1e5a94f7 --- /dev/null +++ b/crews/content-producer/skills/expert-design/workflows/brand-visual.md @@ -0,0 +1,31 @@ +# Workflow:Brand Visual(品牌视觉体系构建) + +任务类型:为品牌建立可复用的视觉规范(色彩 / 字体 / 组件 / 间距 / 阴影 / 响应式 / 护栏)。Step 1/2/3/6/7 按 `expert-design` SKILL.md 的通用骨架执行,本文只写本类型的 brief 必含字段与 Step 5 差异。 + +## Step 2:brief 必含字段 + +- 品牌定位(行业、目标客群、核心价值) +- 风格方向(1–3 个关键词,如"专业 + 科技 + 温暖") +- 现有品牌资产(Logo、已有色彩偏好、字体授权情况;给绝对路径) +- 应用场景(官网 / APP / 社交媒体 / 印刷品…)——决定 token 粒度与暗色表面是否必需 + +## Step 5:构建完整 DESIGN.md(八段) + +1. Visual Theme & Atmosphere:设计哲学、情感基调、密度 +2. Color Palette & Roles:语义名 + hex + 功能角色 +3. Typography Rules:字体族 + 完整层级表 +4. Component Stylings:核心组件样式 + 状态 +5. Layout Principles:间距系统、网格、留白哲学 +6. Depth & Elevation:阴影系统、表面层级 +7. Responsive Behavior:断点、触控目标、折叠策略 +8. Do's and Don'ts:设计护栏 + +## Step 5 另编写组件预览页(preview.html) + +- 展示色彩色板、字体层级、按钮 / 卡片 / 输入框等核心组件 +- 包含亮色与暗色两种表面 +- 所有样式走 CSS custom properties,便于直接复用到后续页面 + +## 交付 + +DESIGN.md + preview.html 落 `output/`;把 DESIGN.md 核心信息(品牌色、字体、护栏)同步到 MEMORY.md 的 Brand Assets 区——**同步前须经甲方确认**。回报绝对路径 + 视觉 review 结论。 diff --git a/crews/content-producer/skills/expert-design/workflows/web-page.md b/crews/content-producer/skills/expert-design/workflows/web-page.md new file mode 100644 index 00000000..427ffa90 --- /dev/null +++ b/crews/content-producer/skills/expert-design/workflows/web-page.md @@ -0,0 +1,29 @@ +# Workflow:Web Page(完整网页 / 落地页设计) + +任务类型:产品介绍页、活动落地页、团队介绍页、404 页等完整网页。Step 1/2/3/6/7 按 `expert-design` SKILL.md 的通用骨架执行,本文只写本类型的 brief 必含字段与 Step 4/5 差异。 + +## Step 2:brief 必含字段 + +- 页面类型(产品介绍页 / 活动落地页 / 团队介绍 / 404 页…) +- 页面清单与信息架构(Sections 列表,按顺序) +- 交互功能范围(纯静态展示 / 含表单 / 含轮播 / 含锚点导航…) +- 风格参考(品牌名或描述词,供 Step 3 `design-full pick` 使用) +- 是否需要深色模式 +- 品牌约束(品牌色、字体、Logo——从 MEMORY.md 或甲方素材取,注明绝对路径) +- 文案来源:甲方给终稿则逐字使用;未给时明确由谁补,不得自造卖点与数据 + +## Step 4:素材 + +页面所需配图 / 背景图 / 参考图:`pexels-footage` / `pixabay-footage` 优先,`siliconflow-img-gen` 备选,全部落 `source/` 并记 `prompts.json`。 + +## 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`;回报绝对路径 + Step 6 视觉 review 结论 + 遗留问题(如缺文案、缺真实数据占位)。 diff --git a/crews/content-producer/skills/expert-video/SKILL.md b/crews/content-producer/skills/expert-video/SKILL.md new file mode 100644 index 00000000..8e737e77 --- /dev/null +++ b/crews/content-producer/skills/expert-video/SKILL.md @@ -0,0 +1,223 @@ +--- +name: expert-video +description: 视频制作专家技能包 +metadata: + openclaw: + emoji: 🎬 + requires: + bins: + - python3 + - ffmpeg + - ffprobe +--- + +# 视频制作专家(expert-video) + +## 我是乙方 + +不管活儿来自谁,我都是**乙方(承制方)**:按 Brief 交付成片与封面,不自作主张改需求,也不替甲方做选题、标题、简介与发布运营。角色定位、两种工作模式与甲乙方硬边界的完整版见 `AGENTS.md`;本文只写视频制作特有的部分。 + +- **Brief 是唯一契约**:Brief 没写的先问甲方,不自行脑补品牌事实、授权与承诺。 +- **制作实现归我**:工作区、分镜、素材方案、剪辑手法、渲染参数由我定,不反过来找甲方要这些决策。 +- **交付边界**:成片 `video.mp4` + 封面 `cover.jpg` + 交付说明 `final-deliver.md`,回报三者**绝对路径**。不发布到任何平台、不私信用户、不代拟运营话术、不替甲方把成片拷进平台目录。 + +### Stage 0 按模式分两种做法 + +| 模式 | 甲方 | Stage 0 我做什么 | +|------|------|------------------| +| A · Subagent 承制 | main agent | 读 Brief → 核对字段齐全 → 缺关键字段向 Brief owner 澄清;**不重开需求讨论** | +| B · 直接对接用户 | 用户(已配 channel) | 用户已给 Brief → 核对确认;没给 → 引导讨论并**代用户整理 `brief.md`,发用户确认后才开工** | + +模式 B 的用户不一定专业,我要替他把需求收敛清楚: + +1. **先明确 Brief**:至少问清做什么类型视频、给谁看、要传达什么、时长与横竖屏、有没有现成素材、要不要口播(谁的声音)、什么时候要;整理成 `brief.md` 发用户确认。模糊想法("做个短片""帮我策划一下")**不算确认**,不得据此调渲染类工具。 +2. **素材必须落实位置**:让用户给出**绝对路径**(或明确授权从哪个目录取),逐条 `ls` 确认真实存在、可解码;缺什么明说,不许拿"待补"开工。 +3. **口播内容**:口播文案由甲方出(模式 A 是 main agent,模式 B 是用户)。用户只给大意时我可代拟,但必须发用户确认定稿;明确是用户真人口播时,必须拿到录音文件(绝对路径)。 + +### 甲方交付什么、我交付什么 + +| 甲方给我 | 要求 | +|----------|------| +| `brief.md` | 绝对路径。含视频类型 / workflow、主题与观看理由、核心传达、业务植入与 CTA(植入位置与方式、内容与业务的衔接句要求、CTA 主目标与句式)、时长与横竖屏、素材清单、封面要求、交付与验收、闸门批准人。**不含 DNA 信息**(甲方内部资产,我不读也不用) | +| 已有素材 | 绝对路径逐条列出,含来源与授权说明;我只做入库校验与技术处理 | +| 口播文案 / 录音 | 有口播时甲方出具 `voiceover.md`(绝对路径);我不重写策略文案,只做声画实现。真人口播时给录音文件绝对路径 | + +- ✅ 缺字段 → 向 Brief owner 澄清后再开工。 +- ❌ 缺字段 → 自己猜品牌卖点、自己编授权、自己改需求方向。 + +| 我给甲方 | 位置 | +|----------|------| +| 成片 | `/video.mp4`(过 `video-review`,verdict=pass;响度已归一化) | +| 封面 | `/cover.jpg`(含封面主文案,主文案来自 Brief) | +| 交付说明 | `/final-deliver.md`:素材来源与授权、各段实际时长、自检结果、弃用中间产物、fallback 决策、遗留问题 | + +## 这个包怎么读(三层) + +| 层 | 是什么 | 怎么用 | +|----|--------|--------| +| **通用制作流程**(本文下方) | 我做**任何**视频制作工作都必须遵循的准则:Stage 0→14 阶段链、GATE A / GATE B 两闸门、返工与耗时上限、决策审计链、工作区与交付约定 | 永远适用,不因视频类型而跳过或替换 | +| **类型 workflow**(`workflows/<值>.md`) | 在通用制作流程**之上**对某一类视频的进一步细化与明确化:阶段裁剪、叙事套路约束、声音 / 画面规范、验收补充 | Brief 指定 `workflow` 时必读必用,按其细化执行;细化内容与通用流程冲突时以 workflow 为准,但**闸门与护栏不让步** | +| **工具说明**(`tools/<工具>/SKILL.md`) | 每个子命令的入参、产物路径、退出码与旁路条件 | 调用前查;本文不重复参数细节 | + +> 通用制作流程**不是**与类型 workflow 并列的第四条路,也**不是**"Brief 没指定类型时的 fallback"。它是底座;类型 workflow 只在底座上细化,产出特定类型的视频。 + +命名约定:Workflow 名与 Tool 名是包内**逻辑资源名**,不是 Workspace 路径,不要拼成相对路径执行;只有工具清单里列出的 wrapper 名能直接当 shell 命令调用。`output_videos/`、`design_assets/` 才是 Workspace 相对路径(从 Content Producer workspace 根解析)。 + +## 类型 workflow(细化层) + +| 视频类型 / 入口信号 | workflow | Brief `workflow` 值 | 它细化了什么 | +|--------------------|----------|---------------------|--------------| +| 影视解说 / 剧情解说 + 突然反转插入品宣("万万没想到"式) | Reversal Ad | `reversal-ad` | 三段结构占比、反转点落在 55%–76%、四种反转手法、反转幅度与接入丝滑度两轴(含二次反转 CTA)、素材三模式 sourcing(Blender 片库 / 用户直供三查 / AIGC)、意象桥与钩连句、植入段约束 | +| 口播类(甲方交付口播文案或真人录音,要合成声画) | Narration Video | `narration-video` | 口播稿落稿锁定不重写、按字级时间戳配画面、声音规范与验收清单 | +| "把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴" | Collage B-roll | `collage-broll` | 一句文稿 → 一个视觉隐喻 → 静帧 → i2v,三道闸门与 Gate 3 批量调度 | + +- Brief 指定了 `workflow`:**先读对应文档并直接采用**,不得替换成自创流程。 +- Brief 未指定:仍走通用制作流程,由 Stage 1 `intent-router` 定档位——故事讲述型 narrative / 纯画面动效型 motion / 蒙太奇剪接型 montage。 +- 已有素材只要剪辑、修整、拼接、配音、烧字幕:仍走通用制作流程,中间阶段按实际裁剪,重心落在 Stage 12 工具箱(只做几何级修整;语义级高光剪辑归甲方 main)。**活儿小也不跳过** Stage 0 Brief 确认、Stage 13 自检与响度归一化、Stage 14 交付三件套。 + +不属于我的活(交回甲方或转其他专家包): + +- 平面设计 / 网页 / APP 界面 / 品牌视觉 → `expert-design` +- 语义级高光剪辑(去口气词、智能剪重点)→ main 的 `talking-head-cut` / `video-edit` +- 视频下载、爆款拆解、转录抽帧 → main 的 `viral-chaser`(我不自己下载转写) +- 平台发布与运营 → main 的各平台专家包 + +## 工作区 + +**每个活儿自建工作区**,甲方不指定、也不代建。落在 Content Producer workspace 的 `output_videos//`;slug 取自 Brief 的视频名或主题英文短横线式,便于与甲方对账。 + +``` +output_videos// # +├── brief.md # 甲方交付(拷贝入档)或 Stage 0 与用户定稿 +├── voiceover.md # 甲方交付的口播文案(如有) +├── reference/ # 可选:甲方给的参考拆解报告与差异化概念 +├── script/ # intent.json(1) / story.md(2) / script.md(3) / self-eval.json(3b) / decisions.json(审计链) +├── storyboard/ # storyboard.json(4) / shot_decompose.json(5) +├── characters/ # registry.json(6) + /{front,side,back}.png +├── gates/ # gate-a.md / gate-b.md(含批准人与批准范围) +├── raw_materials/ # 甲方素材入库副本 + 授权记录 +├── slots/ # slot-plan.json(7) / asset-resolve.json(8) / slideshow-risk.json(9a) / delivery-promise.json(9b) +├── render/shot-NN/ # (10) first-frame.png / last-frame.png / shot.mp4 +├── audio/ # narration.mp3 / narration-segments.json / bgm.mp3 / subtitles.srt +├── artifacts/ # (12) 按镜顺序的最终段 01_*.mp4 … NN_*.mp4 +├── video.mp4 # (12) 成片 +├── review/ # verdict.json(13a) / frames/ / motion-audit.json(13b) +├── cover.jpg # (14a) +└── final-deliver.md # (14b) +``` + +workflow 文档在技能包内,不是项目目录内容;项目目录只放 Brief、素材、脚本、渲染与交付产物。 + +## 通用制作流程(Stage 0→14,两闸门) + +**我做任何视频都走这条链**;类型 workflow 只在此基础上裁剪与细化。每段的子命令是 `video-producer` 工具下的一个独立脚本,按流程逐个调。 + +``` +Stage 0 Brief 确认 模式 A:读甲方 Brief,核对字段,缺口向 Brief owner 澄清 + 模式 B:用户未给 Brief 时引导讨论 → 代拟 brief.md → 发用户确认 + (两种模式的 Stage 0 都是"先把 Brief 定下来",无子命令) +Stage 1 intent-router 定档位(Brief 指定 workflow 时按该 workflow 的约束校验,未给定时从下面三个档位选一个) + narrative 故事讲述型(重情节、有人物弧光、含旁白,默认 3–5 镜/场) + motion 纯画面动效型(重节奏与视觉冲击、少对白,默认 5–8 镜快切) + montage 蒙太奇剪接型(重氛围、抽象、纯视觉,默认 4–7 镜无叙事) +Stage 2 story-develop idea → 故事(受众/类型显式复述、100–200 词梗概、人物、分场) + 甲方已交付口播文案时跳过:叙事以口播稿为准,不另起故事 +Stage 3 script-write 故事 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) + 甲方已交付口播文案时改为落稿锁定:原样落 script/script.md,不重写策略文案 +Stage 3b script-self-eval 脚本自评 N 维打分,任一维 <3 必返工(落稿锁定时只检查不改写) +Stage 4 storyboard-build 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) +Stage 5 shot-decompose 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) +Stage 6 character-register 角色三视图 front/side/back + static/dynamic features 拆分 + ────── GATE A:文本闸门(脚本+分镜+机位+角色全齐,停,发甲方审)────── +Stage 7 slot-plan 素材 slot 规划(template + hero slot + tone→slot 数) +Stage 8 asset-resolve 按 slot 取素材(Fast path:多源并发搜 + 缩略图人核 + rejected_picks 落盘) + 甲方已给素材时:先入库校验(可解码、分辨率/帧率/时长/音轨、授权记录),缺口才补搜 +Stage 9a slideshow-risk 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail 不许进 compose) +Stage 9b delivery-promise-lock 交付承诺八类锁定 + motion_ratio 预估 + ────── GATE B:素材闸门(素材齐+计划过审,停,发甲方看 contact sheet)────── +Stage 10 render-shot 按 slot 渲染(AIGC 走 aigc-video-gen i2v 首尾帧插值;静图走 siliconflow-img-gen) + motion-graphics Stage 10 第二条渲染路径:程序化逐帧动态图形(声明式 spec,产品段动效/标题动画/ + 录屏圈选;确定性渲染不走 AIGC,与 render-shot 并列按镜头性质二选一) +Stage 11 mix-audio 配音配乐四场景分流(A 人物对话声画同出 / B 旁白一次性 TTS 带字级时间戳 + 对齐 / + C BGM 成片后统一生成(优先 bgm-library 免版税曲库,pexels/pixabay 并列;定制风格用 + aigc-video-gen music)/ D 甲方口播录音 → ASR 时间戳 → 按时间戳补素材) + narration-layout 逐句 TTS 模式(每句独立 mp3):对齐镜头起点 + 防重叠守卫 + 越界断言 + SRT + 可选混音; + 整段模式的时间戳对齐仍走 narration-align,两者互补 +Stage 12 assemble 按序拼接成片(原子工具箱,见下节,我按场景组合,不写死流程) +Stage 13a video-review 公共 video-review 技术自检(强制闸门,verdict=pass 才继续) +Stage 13b motion-audit motion_led 抽查(兑付 delivery-promise) +Stage 13c normalize 响度归一化到 -14 LUFS(**必跑**:`video-producer normalize`) +Stage 14a make-cover 封面(siliconflow-img-gen,必含封面主文案) +Stage 14b 交付 回报成片 + 封面 + final-deliver.md 的绝对路径与关键参数 +``` + +- **产物文件存在性即 checkpoint**:子命令先查产物文件是否存在,存在则 load 不重生成(允许手改 JSON 后续跑);要改哪段就重跑对应子命令,未改的不会重生成。 +- Stage 0–6 全是**文本产物**,付费生成前必停——GATE A 落在这条边界上;GATE B 落在素材就绪、pre-compose 闸门通过后,确认渲染前最终计划。 +- 类型 workflow 指定时,阶段裁剪以该 workflow 文档为准;**闸门位置与"停下发甲方"的纪律不变**。甲方已在 Brief 中代理批准某道闸门时,把批准范围落 `gates/` 后继续。 +- 可选工具 `reference-concepts`:甲方给了参考视频拆解报告时,据报告出 2–3 个差异化概念落 `reference/concepts.md`。 + +## 闸门与护栏 + +### GATE A(Stage 6 后):文本闸门 + +文本产物全齐(脚本 + 分镜 + 机位 + 角色),**停下发甲方审**: + +- 呈交摘要:档位或 workflow、场次数、镜数、角色数、关键决策(路径 / 模型 / 风格选择的备选 + 置信度 + 理由) +- **结束本轮回复**,不许在同条回复里进 Stage 7 +- 批准人是 Brief owner(模式 A = main agent,模式 B = 用户);甲方已在 Brief 中代理批准时,把批准范围落 `gates/gate-a.md` 后继续 +- 批准是**逐闸门的**——早先的一句"你继续"不覆盖本闸门 + +### GATE B(Stage 9 后):素材闸门 + +素材齐 + 计划过 slideshow_risk + delivery_promise 锁,**停下发甲方看 contact sheet**: + +- 呈交:slot 总数、素材就绪率、slideshow_risk 六维分与 verdict、delivery_promise 八类与 motion_ratio 预估、素材 contact sheet +- 授权与来源记录必须一并呈交 +- 收尾纪律同 GATE A(呈交后结束本轮回复,等批准) + +### 返工、耗时与审计 + +- 每阶段最多返工 **3 次**;全片最多 **3 次** send-back +- 每阶段 wall-time 默认上限 **20 分钟**——卡住要报,不要反复撞 +- 技术故障(缺 key、依赖缺失、渲染报错)按 Dispatch Protocol spawn IT engineer,不静默卡死 +- **决策审计链**:每个选择(路径 / 模型 / 风格 / 音色 / 任何 fallback)记 `备选 + 置信度 + 理由`,跨阶段累积进 `script/decisions.json` + +## Stage 12 工具箱(场景化组合,不写死顺序) + +原子子命令(`clip-trim` / `audio-mix` / `timeline-compose` / `scene-compose` / `assemble` / `add-silent-audio` / `make-outro`)的入参与产物见 `video-producer` 工具说明。下面只给组合套路: + +- **无旁白直拼**:段就绪、无需切素材与混音 → `assemble --transition fade` 一把过。 +- **有旁白走时间轴(整段模式)**:旁白一次性 TTS + `narration-align` 拿字级时间戳 → 据各段 start/end 定素材入点出点写 `timeline.json` → `timeline-compose`(内部调 clip-trim 切段 + audio-mix 叠旁白);全片 BGM 走 `timeline.json` 的 `audio_globals` 混入。 +- **逐句旁白守卫排布(逐句模式,解说/反转植入类常用)**:逐句 TTS 出独立 mp3 → 写 `narration_plan.json`(shots 有序清单 + 逐句 file/text/shot_id + BGM + 守卫参数)→ `narration-layout` 出 `abs_starts.json` + SRT + 可选 mix → `assemble --manifest segments.json --verify-fps 25 --expect-durations slots/shotdur.json` 拼接并断言(**不传 --transition,hard 直拼**:fade/xfade 吃重叠会平移时间轴使排布失效)→ `burn-srt --force-style`(样式串直接取 abs_starts.json 的 force_style)→ `normalize`。**守卫断言失败改计划(镜头时长/文案),不放宽容差硬过。** +- **产品段/标题动态图形**:写 `mg-*.json` spec(四模板或基础元素组合)→ `motion-graphics` 出单段 clip → 段进 manifest 一起 `assemble`。超出模板的特制动画走 spec 的 `custom` 插件逃生舱(plugin 只画帧,编码/checkpoint/时长校验仍在子命令),**不整段手写渲染脚本**。 +- **分段先合再合**:长片或某些段需独立预合 → 写 `scene-01.json`(clips + narration + dialogue)→ `scene-compose` 出 `scene-01.mp4`,同法出 `scene-02.mp4` → 两个 scene 当段素材 `assemble --source-dir scenes --transition fade`。 +- **素材尺寸不一**(AIGC 720x1280 / 录屏 1080x2384 / 片尾 784x1176 混拼):`assemble --width 1080 --fps 30` 归一化后再 concat。 +- **精确调速某段**:`clip-trim --speed 2 --sync-audio`,快放段当段素材再拼。 +- **低内存机器**:`assemble --low-memory`(preset=ultrafast、crf=28),避免 x264 缓冲爆内存。 +- **先试听再合成**:`assemble --preview-duration 30`,成片照常落,额外产 `video-preview.mp4`。 +- **AIGC 无音频段混拼 + 旁白切段吞首字**:`assemble` 已自动统一音频格式(默认 24000/mono,无音频段补静音)、不传 `--width/--fps` 时自动统一到最低公共规格;旁白切段走 `clip-trim --pre-buffer 0.5`。 + +## 工具与依赖 + +| 工具 | 用途 | 命令 | +|------|------|------| +| `video-producer` | 阶段链全部原子能力(意图路由、故事 / 剧本 / 分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面)+ 后期处理(`normalize` **必跑**、`burn-srt` / `duck` / `denoise` / `interp` 可选,全部干湿分离不覆盖输入) | `video-producer <子命令>`;`video-producer help` 列全量 | +| `collage-broll` | 纸拼贴 B-roll 的环境自检与 Gate 3 批量 i2v 调度(0 全通 / 1 参数错 / 2 部分失败,只重跑失败条目) | `collage-broll check-setup` / `collage-broll gate3 --batch [--dry-run]` | + +跨领域公共技能:`aigc-video-gen`(视频片段生成 / i2v 首尾帧插值,Stage 8/10;输出路径须落在 `output_videos/` 下,调用时 workdir 是 Content Producer workspace 根)、`siliconflow-img-gen`(静帧、角色三视图、封面,Stage 6/10/14a)、`awk-tts`(旁白 TTS,带字级时间戳,Stage 11B;`--enable-subtitle` 让火山流式 HTTP 原生返回时间戳)、`bgm-library`(ccMixter 免版税 + 自动 TASL 署名,商用安全,Stage 11C 优先)、`pexels-footage` / `pixabay-footage`(免版税素材与 BGM 搜索)、`video-review`(成片技术自检闸门,Stage 13a)、`video-edit subtitles`(main crew 暴露的烧字幕原子;不可用时向 Brief owner 报工具缺口,不手写 ffmpeg)。 + +env 依赖:`AWK_API_KEY`(静帧 / 视频生成)、`VOLC_ASR_*`(`narration-align` 回退路径与甲方口播录音转写;旧控制台双头 `VOLC_ASR_APP_ID` + `VOLC_ASR_ACCESS_KEY`,或新控制台单头 `VOLC_ASR_APP_KEY`)。缺 env 时子命令 exit 2,补齐属 IT engineer 职责,不要静默降级。Python 依赖 `requests`、`Pillow`(`motion-graphics` 逐帧绘制)在仓根 `requirements.txt`。系统依赖:`motion-graphics` 需要 Noto Sans SC/CJK 字体(探测 `/usr/share/fonts/opentype/noto-sc` 等候选目录,缺失 exit 2;可用 spec `font_dir` 或 env `MG_FONT_DIR` 覆盖)。机器资源约束(线程数、分辨率上限、低载编码)读本 workspace `MEMORY.md` 或 Brief 的环境约束,不写死在技能包里(`motion-graphics` 默认即低载:nice19/veryfast/crf18/threads2)。 + +## 禁止事项(强制) + +- **禁止跳过 GATE A / GATE B 交付**:两闸门是流程的一部分,呈交摘要后必须结束本轮回复等甲方批。 +- **禁止跳过 `video-review` 与响度归一化交付**:Stage 13a verdict=pass、Stage 13c `normalize` 已跑,才进 Stage 14。 +- **禁止声称没做过的事**:没有 tool result 或产物文件证明,不许声称已渲染 / 已生成 / 已改动。 +- **禁止替甲方做需求决策**:选题方向、品牌事实、卖点承诺、业务植入与 CTA 口径、发布文案不由我定;Brief 没写就问。 +- **禁止让甲方建工作区**:工作区自建;也不要把中间产物写进甲方(main / 用户)的目录。 +- **禁止把模糊想法擅自扩成多场多镜**:默认 1 场 3–5 镜,甲方要扩才扩。 +- **禁止直接写 ffmpeg 命令**:所有 ffmpeg 调用走 `video-producer` / `collage-broll` 子命令或公共技能子命令;唯一例外是 workflow 文档里给出的既定 ffmpeg 模板(如 Collage B-roll 的首尾帧处理与 contact sheet 拼图),照抄执行不自创。 +- **禁止自己做视频下载 / 转写 / 抽帧**:那是 main 的 `viral-chaser` 的活。 +- **禁止引入 CLIP / torch 系本地模型**:素材匹配走 Fast path 人核缩略图。 +- **禁止扩充图库源**:保 Pexels + Pixabay 两源。 +- **禁止批量生成撞运气**:逐条精做。 diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md b/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md new file mode 100644 index 00000000..a4383138 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/SKILL.md @@ -0,0 +1,32 @@ +--- +name: collage-broll +description: 纸拼贴 B-roll 的原子工具——环境自检与 Gate 3 批量 i2v 视频生成调度。 +--- + +# collage-broll — 工具说明 + +> 本文是 `expert-video` 专家包内的工具说明书,不独立出现在技能列表中。制作流程(视觉隐喻 → 静帧 → 视频三道闸门)由包内 Collage B-roll Workflow 编排。 + +**用途**:代理纸拼贴 B-roll 的两个脚本,让 agent 走 PATH 调用、零路径拼接。 + +**输入 / 输出**: + +| 子命令 | 入 | 出 | 退出码 | +|--------|----|----|--------| +| `collage-broll check-setup` | 无(读环境变量与 PATH) | stdout 逐项 PASS/FAIL | 0 全通 / 1 有缺项 | +| `collage-broll gate3` | `--batch /gen-jobs.json`(每条含 prompt / first_frame / last_frame / output / ratio / resolution / duration),可选 `--dry-run` | 逐条调公共 `aigc-video-gen` i2v 落 MP4 + decisions.log | 0 全通 / 1 参数错、jobs 文件不存在或格式错、`aigc-video-gen` 不在 PATH / 2 部分 job 失败(stderr 报失败清单,已跑通的保留) | + +**调用方式**: + +```bash +collage-broll check-setup +collage-broll gate3 --batch output_videos//gen-jobs.json --dry-run +collage-broll gate3 --batch output_videos//gen-jobs.json +``` + +**注意事项**: + +- `gate3` 串行调度:视频生成是异步轮询任务,并行会撞平台并发限。 +- `gen-jobs.json` 的 `output` 必须是相对 workspace 根、且落在 `output_videos/` 下的路径(`aigc-video-gen` 的 ensure_safe_output 要求);调用时 workdir 必须是 Content Producer workspace 根。 +- 依赖:`ffmpeg` / `ffprobe` / Python ≥ 3.10;`AWK_API_KEY`(Gate 2 静帧);`MODELSTUDIO_API_KEY` 或 `DASHSCOPE_API_KEY`(百炼)或 `AWK_GEN_KEY`(火山)。缺项由 `check-setup` 报出,补齐属 IT engineer 职责。 +- 模型候选链 fallback 与 decisions.log 由 `aigc-video-gen` 自带,本工具不重复实现。 diff --git a/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh b/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh new file mode 100755 index 00000000..60d36095 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/collage-broll/collage-broll.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# collage-broll.sh — collage-broll 工具 wrapper(薄转发,子命令范式) +# 让 agent 用 `collage-broll <子命令> [参数...]` 走 PATH,零路径拼接。 +# 子命令: +# check-setup 环境自检(ffmpeg/ffprobe/AWK_API_KEY/视频平台 key/python 版本) +# gate3 --batch [--dry-run] Gate 3 批量调度 i2v 生成 +# 纸拼贴 B-roll 的完整制作流程见 expert-video 包内 Collage B-roll Workflow。 +set -euo pipefail +SELF="${BASH_SOURCE[0]}" +# Resolve symlink (wrapper is ln -sfn'd into ~/.openclaw/bin) so SCRIPT_DIR points at the real tool dir. +while [ -L "$SELF" ]; do SELF="$(readlink -f "$SELF")"; done +SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" + +SUBCMD="${1:?用法: collage-broll [参数...]}" +shift +case "$SUBCMD" in + check-setup) + exec bash "$SCRIPT_DIR/scripts/check_setup.sh" "$@" + ;; + gate3) + exec python3 "$SCRIPT_DIR/scripts/run_gate3.py" "$@" + ;; + -h|--help|help) + cat <<'HELP' +collage-broll — 纸拼贴 B-roll 原子工具(wrapper) + +用法: + collage-broll check-setup 环境自检(依赖与 key) + collage-broll gate3 --batch Gate 3 批量 i2v 生成(串行调 aigc-video-gen) + collage-broll gate3 --batch <...> --dry-run 只打印调度计划不真调 + collage-broll help 本帮助 + +退出码: + check-setup 0 全通 / 1 有缺项 + gate3 0 全部 job 跑通 / 1 参数错或 wrapper 缺失 / 2 部分 job 失败(已跑通的保留) + +制作流程(隐喻 → 静帧 → 视频三道闸门)见 expert-video 包内 Collage B-roll Workflow; +本 wrapper 只代理脚本,不含流程语义。 +HELP + ;; + *) + echo "未知子命令: $SUBCMD" >&2 + echo "用 collage-broll help 查可用子命令" >&2 + exit 1 + ;; +esac diff --git a/crews/content-producer/skills/collage-broll/scripts/check_setup.sh b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh similarity index 100% rename from crews/content-producer/skills/collage-broll/scripts/check_setup.sh rename to crews/content-producer/skills/expert-video/tools/collage-broll/scripts/check_setup.sh diff --git a/crews/content-producer/skills/collage-broll/scripts/run_gate3.py b/crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_gate3.py similarity index 100% rename from crews/content-producer/skills/collage-broll/scripts/run_gate3.py rename to crews/content-producer/skills/expert-video/tools/collage-broll/scripts/run_gate3.py diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md b/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md new file mode 100644 index 00000000..39a5acc5 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/SKILL.md @@ -0,0 +1,89 @@ +--- +name: video-producer +description: 视频制作原子能力集——意图路由、故事/剧本/分镜、素材 slot 与解析、渲染、混音对齐、拼接合成、动效审计、封面。子命令范式,产物文件存在性即 checkpoint。 +--- + +# video-producer — 工具说明 + +> 本文是 `expert-video` 专家包内的工具说明书,不独立出现在技能列表中。制作流程(阶段链、两闸门、workflow 选择)由包内 SKILL.md 与 `workflows/` 编排,本文只写每个子命令的输入、输出与调用方式。 + +**调用方式**:`video-producer <子命令> [参数...]`(wrapper 转发到 `scripts/<子命令>.py`,子命令名即脚本名,零路径拼接)。`video-producer help` 列可用子命令。 + +**通用约定**: + +- 多数子命令第一个位置参数是 ``(工作区目录),产物落该目录下约定子路径。 +- **产物文件存在性即 checkpoint**:子命令先查产物文件是否存在,存在则 load 不重生成(允许手改 JSON 后续跑)。 +- 退出码:`0` 成功 / `1` 参数错 / `2` env 未配(如 `AWK_API_KEY`、`VOLC_ASR_*`)。 + +## 子命令清单 + +| 子命令 | 入 | 出 | 用途 | +|--------|----|----|------| +| `intent-router` | brief.md(主题/关键词/类型) | `script/intent.json`(档位+主题) | 意图路由三档:故事讲述型 narrative / 纯画面动效型 motion / 蒙太奇剪接型 montage | +| `reference-concepts` | 甲方给的参考拆解报告(可选) | `reference/concepts.md` | 据报告出 2–3 个差异化概念;不做下载/转写/抽帧 | +| `story-develop` | intent.json | `script/story.md` | idea → 故事(受众/类型复述、100–200 词梗概、人物、分场) | +| `script-write` | story.md | `script/script.md`(含 enhancement_cues 六型 + delivery_cues) | 故事 → 分场剧本(同时间同地点分一场、可拍化描述、enhancer 润色) | +| `script-self-eval` | script.md | `script/self-eval.json` | 脚本自评 N 维打分,任一维 <3 必返工 | +| `storyboard-build` | script.md | `storyboard/storyboard.json` | 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) | +| `shot-decompose` | storyboard.json | `storyboard/shot_decompose.json` | 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) | +| `character-register` | storyboard.json + brief.md | `characters/registry.json` + 三视图 png | 角色 static/dynamic features 拆分 + front/side/back(调 `siliconflow-img-gen`) | +| `slot-plan` | storyboard.json + shot_decompose.json | `slots/slot-plan.json` | 素材 slot 规划(template + hero slot + tone→slot 数) | +| `asset-resolve` | slot-plan.json | `slots/asset-resolve.json`(含 rejected_picks)+ 素材落 `raw_materials/` | 按 slot 拉素材(Fast path:多源并发搜 + 缩略图人核;调 pexels-footage / pixabay-footage / aigc-video-gen) | +| `slideshow-risk` | storyboard.json + slot-plan.json + asset-resolve.json | `slots/slideshow-risk.json` | 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail) | +| `delivery-promise-lock` | storyboard.json + brief.md | `slots/delivery-promise.json` | 交付承诺八类锁定 + motion_ratio 预估 | +| `render-shot` | shot_decompose.json + characters/ + slot-picks | `render/shot-NN/` 下产物 | 按 slot 渲染(AIGC 走 `aigc-video-gen` i2v 首尾帧插值;静图走 `siliconflow-img-gen`) | +| `motion-graphics` | ` --spec mg.json [--out clip.mp4] [--duration] [--force]` | 单个动态图形 clip.mp4(默认 `render/mg//`) | 程序化逐帧动画(Stage 10 第二条渲染路径,与 render-shot 并列):声明式 spec,内置四模板(dimension_grid 逐维点亮 / scroll_cards 滚动卡组 / crew_panel 角色卡入位 / rec_highlight 录屏圈选)+ 基础元素(text/card/photo_circle/glow/band/highlight_zone/progress_bar)+ custom 插件逃生舱;帧级 checkpoint、时长/帧率断言;只出单段,不拼接不混音不做字幕 | +| `mix-audio` | script.md(delivery_cues) | `audio/` 目录 + `subtitles.srt` 模板 | 配音配乐四场景分流:A 人物对话声画同出 / B 旁白一次性 TTS 带字级时间戳 + 对齐 / C BGM 成片后统一生成 / D 甲方口播录音 → ASR 时间戳 → 按时间戳补素材 | +| `narration-align` | audio/narration.mp3 + audio/narration.subtitle.json | `audio/narration-segments.json` | 旁白字级时间戳对齐(**整段模式**:一条连续 narration.mp3;优先复用 `awk-tts --enable-subtitle` 的原生时间戳,缺失时回退火山 ASR 极速版,凭据 `VOLC_ASR_*`) | +| `narration-layout` | ` --plan narration_plan.json [--srt ...] [--mix ...] [--force]` | `audio/abs_starts.json` + SRT + 可选混音 | 逐句旁白排布(**逐句模式**:每句独立 mp3,与 narration-align 互补):实测镜头时长累积起点 → 每句对齐镜头起点 + 防重叠守卫 → 逐句/末句越界断言(违反非零退出打印明细)→ SRT(样式参数化,force_style 落 abs_starts.json 供 burn-srt 引用)→ 可选一步混音(内部复用 audio-mix:N 路旁白 + BGM fade) | +| `clip-trim` | `--input/--output/--start/--end/--speed/--sync-audio/--pre-buffer/--duration/--normalize/--grade/--windows/--zoompan/--low-load` | 切好的片段 | 精确切素材段(入点/出点/倍速/前置缓冲,视频、音频、图片分别处理;`--pre-buffer 0.5` 防切 MP3 吞首字);`--normalize 1920x1080@25` 切片即归一(scale+pad+sar+fps);`--grade warm` 预设调色;`--windows "12.0:3.2,44.5:1.4"` 一镜多窗切后 concat;`--zoompan 1.08` 定帧缓推(视频源在 --start 取帧);`--low-load` 低载编码(nice19/veryfast/crf18/threads2) | +| `audio-mix` | `--track(可重复)/--delay/--volume/--fadein/--fadeout/--output/--duration` | 混合音频 | 多轨混音(每轨独立延时、音量与淡入淡出;`--duration` 为硬上限:短轨补虚、超出截断) | +| `timeline-compose` | ` --timeline timeline.json [--transition ...]` | 合成片段 | 按时间轴 JSON 调 clip-trim + audio-mix 合成(`audio_mode=concat` 出连续轨;`audio_globals` 混全片 BGM) | +| `scene-compose` | ` --scene scene.json [--output scene-01.mp4]` | 单 Scene 片段 | 分段合成(clips + narration + dialogue → 一个 Scene);内部调 clip-trim + audio-mix + assemble | +| `assemble` | ` [--transition hard/fade/dissolve/xfade] [--width] [--fps] [--audio-format] [--low-memory] [--preview-duration] [--source-dir] [--manifest] [--verify-fps] [--expect-durations] [--duration-tolerance]` | `video.mp4`(+ 可选 `video-preview.mp4`) | 按序拼接成片:可选转场、分辨率/帧率归一化、自动统一音频格式(无音频段补静音)、低内存模式(ultrafast/crf28)、前 N 秒试听版;`--manifest segments.json` 显式有序段清单(手写/motion-graphics 管线产物绕开 shot-NN 命名约定);`--verify-fps 25` 拼接后帧率断言;`--expect-durations plan.json` 逐段时长 vs 计划 ± 容差校验(兼容 shotdur.json 的 beats 形态) | +| `add-silent-audio` | `--input/--output/--duration/--sample-rate/--channels` | 含静音音轨的视频 | 给无音频片段补静音轨(concat 前置;assemble 内部也自动调) | +| `make-outro` | ` --image <形象图> --slogan <文本> [--color color.json] [--duration 5] [--width 1080] [--fps 30]` | 标准比例片尾段 | 形象图 + 黑边 + 烧字幕 + 静音轨 | +| `motion-audit` | video.mp4 + delivery-promise.json | `review/motion-audit.json` | motion_led 抽查(兑付交付承诺) | +| `make-cover` | brief.md(封面主文案)+ storyboard 关键帧 | `cover.jpg` | 封面生成(调 `siliconflow-img-gen`,必含封面主文案) | + +## 注意事项 + +- **不自己下载/转写/抽帧**:参考视频拆解归 main 的 `viral-chaser`;本工具只吃甲方给的报告或素材。 +- **不引入 CLIP / torch 系本地模型**:素材匹配走 Fast path 人核缩略图。 +- **图库源固定**:Pexels + Pixabay 两源,不扩充。 +- **AIGC 输出路径约束**:`aigc-video-gen` 要求输出相对路径落在 `output_videos/` 下,调用时 workdir 必须是 Content Producer workspace 根。 +- **env 依赖**:`AWK_API_KEY`(静帧/视频生成)、`VOLC_ASR_*`(narration-align 回退与口播录音转写)。缺 env 时子命令 exit 2,补齐属 IT engineer 职责,不要静默降级。 +- **闸门不是子命令**:GATE A / GATE B 由 agent 按包内 SKILL.md 执行(呈交摘要 → 结束本轮回复 → 等甲方逐闸门批准)。 + +## 后期处理子命令(Stage 13c 与可选后期) + +同在 `video-producer` wrapper 下,调用方式与其他子命令一致:`video-producer <子命令> [参数...]`,每个都支持 `--help` 查完整入参。 + +| 子命令 | 用途 | 必跑/可选 | 落点 | +|--------|------|----------|------| +| `normalize` | ffmpeg loudnorm 双 pass 归一化到 -14 LUFS(抖音 / 视频号 / B站竖屏发布通用标准) | **必跑** | 阶段链 Stage 13c:成片合成后、自检与交付前强制跑 | +| `burn-srt` | libass 把 SRT 硬烧进画面(烧录后不可关) | 可选(Brief 或甲方要字幕时) | 归一化前后均可;串联时以上一步产物为输入 | +| `duck` | sidechaincompress 让旁白作 sidechain 触发 BGM 自动压低(默认 threshold -25dB / ratio 8:1) | 可选(要专业混音且可分轨时) | 混音阶段 | +| `denoise` | afftdn(默认)/ arnndn(RNN,要模型文件)去环境噪声 | 可选(仅甲方素材音质差时;AIGC 音轨本来就干净,跳过) | 素材入库后 | +| `interp` | minterpolate 补帧到 30/60fps | 可选(仅低 fps 源材,如 24fps AIGC 片) | 渲染或拼接前 | + +**干湿分离(五个都守)**:输出落 `_<处理名>.mp4`(`_normalized` / `_burned` / `_ducked` / `_denoised` / `_interp`),不覆盖输入;多步串联时下一步以上一步产物为输入(如 ducking 后再 normalize),原产物保留作回退。 + +常用调用: + +```bash +video-producer normalize --output # 默认 -14 LUFS / true peak -1.5 dB / LRA 11 +video-producer burn-srt --output # 默认 Noto Sans CJK SC 24px,可 --font-name/--font-size/--force-style +video-producer duck --output # 视频自带 BGM +video-producer duck --bgm-source --output # 外挂 BGM +video-producer denoise --output # 更强降噪:--method arnndn --rnn-model +video-producer interp --target-fps 30 --output # 更顺但慢:--mode mci(高运动易出鬼影) +``` + +旁路条件(不满足就报错退出,不静默降级): + +- `normalize`:无声轨 / 音频畸变 → exit 2,退回 Stage 12 重生;input_i 已在 target ±0.3 LUFS 内 → 自动跳过渲染直接拷贝 +- `burn-srt`:ffmpeg 不带 libass → exit 1,改发外挂 SRT;SRT 不存在或格式错 → exit 1 +- `duck`:AI 声画同出模式混轨不可分 → 报甲方决策;视频无声轨且没传 `--bgm-source` → exit 1 +- `denoise`:ffmpeg 不带 afftdn/arnndn → exit 1;`--method arnndn` 没传 `--rnn-model` → exit 1 +- `interp`:源 fps ≥ 目标 fps → 自动跳过拷贝;ffmpeg 不带 minterpolate → exit 1;mci 出鬼影 → 退 blend diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_lib.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_lib.py new file mode 100644 index 00000000..34330368 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_lib.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +"""_mg_lib — motion-graphics 基础设施库(非子命令,wrapper 不暴露)。 + +收编自 three-year-search v4 已验证的 _v4_animlib:easing / 字体缓存 / 调色板 / +径向辉光 / 暗底网格 / 帧落盘 / 低载编码。所有函数无副作用(除显式落盘), +motion-graphics.py 与 _mg_templates.py 共用。 +""" + +from __future__ import annotations + +import json +import math +import os +import subprocess +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +# ---------- 常量 ---------- +DEFAULT_W, DEFAULT_H, DEFAULT_FPS = 1920, 1080, 25 +FRAME_QUALITY = 92 # JPEG 帧质量(v4 验证值) +DUR_TOLERANCE = 0.12 # 成片时长 vs 计划容差(秒) + +# 品牌调色板(spec 里可按名引用) +PALETTE = { + "GREEN": (7, 193, 96), + "CYAN": (53, 208, 255), + "GOLD": (255, 194, 77), + "RED": (255, 77, 94), + "INK": (10, 17, 32), + "WHITE": (240, 246, 255), + "MUTED": (150, 165, 188), +} + +# 字体目录候选(按序探测,找到含 Noto Sans SC 四件套的目录即用) +FONT_DIR_CANDIDATES = [ + "/usr/share/fonts/opentype/noto-sc", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/noto-cjk", +] +FONT_FILES = { + "black": ["NotoSansSC-Black.otf", "NotoSansCJKsc-Black.otf"], + "bold": ["NotoSansSC-Bold.otf", "NotoSansCJKsc-Bold.otf"], + "med": ["NotoSansSC-Medium.otf", "NotoSansCJKsc-Medium.otf"], + "reg": ["NotoSansSC-Regular.otf", "NotoSansCJKsc-Regular.otf"], +} + + +class MgError(Exception): + """spec/环境错误,motion-graphics.py 捕获后按退出码约定退出。""" + + def __init__(self, msg: str, code: int = 1): + super().__init__(msg) + self.code = code + + +# ---------- easing ---------- +def clamp(x: float, a: float = 0.0, b: float = 1.0) -> float: + return max(a, min(b, x)) + + +def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def seg(t: float, t0: float, t1: float) -> float: + """t 在 [t0,t1] 窗口内的归一化进度(窗口外 0/1 饱和)。""" + if t1 <= t0: + return 1.0 if t >= t0 else 0.0 + return clamp((t - t0) / (t1 - t0)) + + +def ease_linear(t: float) -> float: + return clamp(t) + + +def ease_out_cubic(t: float) -> float: + t = clamp(t) + return 1 - (1 - t) ** 3 + + +def ease_in_out(t: float) -> float: + t = clamp(t) + return t * t * (3 - 2 * t) + + +def ease_out_back(t: float, k: float = 1.6) -> float: + t = clamp(t) + t -= 1 + return 1 + t * t * ((k + 1) * t + k) + + +EASINGS = { + "linear": ease_linear, + "out_cubic": ease_out_cubic, + "in_out": ease_in_out, + "out_back": ease_out_back, +} + + +def get_ease(name: str): + if name not in EASINGS: + raise MgError(f"未知 easing: {name}(可选 {'/'.join(EASINGS)})") + return EASINGS[name] + + +def pulse(t: float, freq: float) -> float: + """0→1→0 正弦脉冲(freq 为 Hz)。""" + return 0.5 + 0.5 * math.sin(2 * math.pi * t * freq) + + +def rng(seed: int): + """确定性伪随机数发生器(帧间稳定,粒子/数据流用)。""" + state = seed + + def f() -> float: + nonlocal state + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + return state / 0x7FFFFFFF + + return f + + +# ---------- 颜色 ---------- +def parse_color(raw, default: tuple[int, int, int] | None = None): + """spec 颜色:调色板名("GREEN")或 [r,g,b]。""" + if raw is None: + if default is not None: + return default + raise MgError("颜色缺失且无默认值") + if isinstance(raw, str): + key = raw.upper() + if key not in PALETTE: + raise MgError(f"未知颜色名: {raw}(可选 {'/'.join(PALETTE)} 或 [r,g,b])") + return PALETTE[key] + if isinstance(raw, (list, tuple)) and len(raw) == 3: + return tuple(int(c) for c in raw) + raise MgError(f"颜色格式错误: {raw!r}(应为调色板名或 [r,g,b])") + + +def with_alpha(color: tuple, alpha: int) -> tuple: + return (color[0], color[1], color[2], int(clamp(alpha, 0, 255))) + + +# ---------- 字体 ---------- +class FontBank: + """字体缓存:四档字重(black/bold/med/reg),目录按候选序探测。""" + + def __init__(self, font_dir: str | None = None): + self.dir = self._find_dir(font_dir) + self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {} + + @staticmethod + def _find_dir(font_dir: str | None) -> Path: + candidates = ([font_dir] if font_dir else []) + \ + ([os.environ["MG_FONT_DIR"]] if os.environ.get("MG_FONT_DIR") else []) + \ + FONT_DIR_CANDIDATES + for cand in candidates: + d = Path(cand) + if d.is_dir() and any((d / f).is_file() for names in FONT_FILES.values() for f in names): + return d + raise MgError( + "找不到 Noto Sans SC/CJK 字体目录(探测过: " + ", ".join(candidates) + + ");装 fonts-noto-cjk 或在 spec 传 font_dir / 设 MG_FONT_DIR", code=2) + + def get(self, weight: str, size: int) -> ImageFont.FreeTypeFont: + if weight not in FONT_FILES: + raise MgError(f"未知字重: {weight}(可选 {'/'.join(FONT_FILES)})") + key = (weight, size) + if key not in self._cache: + for fname in FONT_FILES[weight]: + p = self.dir / fname + if p.is_file(): + self._cache[key] = ImageFont.truetype(str(p), size) + return self._cache[key] + raise MgError(f"字重 {weight} 在 {self.dir} 下无对应字体文件", code=2) + return self._cache[key] + + +# ---------- 绘制基元 ---------- +def vgrad(w: int, h: int, top: tuple, bottom: tuple) -> Image.Image: + """纵向渐变底。""" + base = Image.new("RGB", (1, h)) + px = base.load() + for y in range(h): + t = y / max(1, h - 1) + px[0, y] = (int(lerp(top[0], bottom[0], t)), + int(lerp(top[1], bottom[1], t)), + int(lerp(top[2], bottom[2], t))) + return base.resize((w, h)) + + +def radial_glow(size: int, color: tuple, max_alpha: int = 140) -> Image.Image: + """软径向辉光 RGBA(中心亮、边缘透明)。""" + g = Image.radial_gradient("L").resize((size, size)) + inv = g.point(lambda v: int(255 - v)) + layer = Image.new("RGBA", (size, size), (color[0], color[1], color[2], 0)) + layer.putalpha(inv.point(lambda v: int(v * max_alpha / 255))) + return layer + + +def safe_composite(base: Image.Image, layer: Image.Image, x: int, y: int) -> None: + """alpha_composite 的安全版:负坐标/超出画幅自动裁剪,完全在画外则跳过。 + + PIL 的 alpha_composite 不接受负 dest 坐标;小画幅或元素越界时会炸,这里统一兜底。 + """ + x, y = int(x), int(y) + if x >= base.width or y >= base.height: + return + if x < 0 or y < 0 or x + layer.width > base.width or y + layer.height > base.height: + x0, y0 = max(0, -x), max(0, -y) + x1, y1 = min(layer.width, base.width - x), min(layer.height, base.height - y) + if x1 <= x0 or y1 <= y0: + return + layer = layer.crop((x0, y0, x1, y1)) + x, y = max(0, x), max(0, y) + base.alpha_composite(layer, (x, y)) + + +def paste_glow(base: Image.Image, center: tuple, size: int, color: tuple, alpha: int) -> None: + gl = radial_glow(size, color, alpha) + safe_composite(base, gl, int(center[0] - size / 2), int(center[1] - size / 2)) + + +def dark_bg(w: int, h: int, grid: bool = True) -> Image.Image: + """标准暗色科技底(纵向渐变 + 细网格),RGBA。""" + img = vgrad(w, h, (9, 15, 28), (16, 27, 48)).convert("RGBA") + if grid: + d = ImageDraw.Draw(img) + step = max(48, w // 20) + for x in range(0, w, step): + d.line([(x, 0), (x, h)], fill=(255, 255, 255, 7)) + for y in range(0, h, step): + d.line([(0, y), (w, y)], fill=(255, 255, 255, 7)) + return img + + +def text_shadow(d: ImageDraw.ImageDraw, xy: tuple, s: str, font, + fill: tuple = (240, 246, 255, 255), + anchor: str = "mm", shadow=(0, 0, 0, 180), off: int = 2) -> None: + d.text((xy[0] + off, xy[1] + off), s, font=font, fill=shadow, anchor=anchor) + d.text(xy, s, font=font, fill=fill, anchor=anchor) + + +def photo_circle(path: Path, size: int) -> Image.Image: + """圆形裁剪头像 RGBA(4x 超采样抗锯齿)。""" + img = Image.open(path).convert("RGBA") + w, h = img.size + side = min(w, h) + img = img.crop(((w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2)) + img = img.resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size * 4, size * 4), 0) + ImageDraw.Draw(mask).ellipse([0, 0, size * 4 - 1, size * 4 - 1], fill=255) + mask = mask.resize((size, size), Image.LANCZOS) + img.putalpha(mask) + return img + + +def load_base_image(path: Path, w: int, h: int, fit: str = "fill", + dim: float = 0.0, blur: float = 0.0) -> Image.Image: + """底图装载:fit=fill(拉满裁切)/ contain(保比黑边),可压暗与模糊。""" + img = Image.open(path).convert("RGB") + if fit == "fill": + img = img.resize((w, h), Image.LANCZOS) + elif fit == "contain": + img.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), (0, 0, 0)) + canvas.paste(img, ((w - img.width) // 2, (h - img.height) // 2)) + img = canvas + else: + raise MgError(f"未知 background.fit: {fit}(可选 fill/contain)") + if dim > 0: + img = Image.eval(img, lambda v: int(v * (1 - clamp(dim, 0, 1)))) + if blur > 0: + img = img.filter(ImageFilter.GaussianBlur(blur)) + return img + + +# ---------- 帧与编码 ---------- +def nframes(dur: float, fps: float) -> int: + return max(2, int(round(dur * fps))) + + +def save_frame_jpg(img: Image.Image, fdir: Path, idx: int) -> Path: + p = fdir / f"f_{idx:04d}.jpg" + img.convert("RGB").save(p, quality=FRAME_QUALITY) + return p + + +def save_frame_png(img: Image.Image, fdir: Path, idx: int) -> Path: + p = fdir / f"f_{idx:04d}.png" + img.save(p) + return p + + +def frame_exists(fdir: Path, idx: int) -> bool: + for ext in (".jpg", ".png"): + p = fdir / f"f_{idx:04d}{ext}" + if p.is_file() and p.stat().st_size > 0: + return True + return False + + +def encode_frames(fdir: Path, out_mp4: Path, fps: float, crf: str, preset: str, + threads: str, nice: str | None, base_video: Path | None = None) -> None: + """帧序列 → mp4(低载编码,tmp 落盘后原子替换)。 + + base_video 传入时为 overlay 模式:帧序列须是 PNG(RGBA 覆盖层), + 与底视频用 ffmpeg overlay 合成。 + """ + pattern = "f_%04d.png" if base_video else "f_%04d.jpg" + tmp = out_mp4.with_suffix(".tmp.mp4") + cmd = (["nice", "-n", nice] if nice else []) + ["ffmpeg", "-y", "-v", "error"] + if base_video: + cmd += ["-i", str(base_video), "-framerate", str(fps), "-i", str(fdir / pattern), + "-filter_complex", "[0:v][1:v]overlay=0:0:format=auto,format=yuv420p", + "-c:v", "libx264", "-crf", crf, "-preset", preset, "-threads", threads, + "-an", str(tmp)] + else: + cmd += ["-framerate", str(fps), "-i", str(fdir / pattern), + "-vf", "format=yuv420p", + "-c:v", "libx264", "-crf", crf, "-preset", preset, "-threads", threads, + str(tmp)] + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + raise MgError(f"编码失败 (rc={p.returncode}): {p.stderr[:500]}") + os.replace(tmp, out_mp4) + + +def probe_duration(path: Path) -> float: + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)], + capture_output=True, text=True, + ) + if p.returncode != 0: + return 0.0 + try: + return float(json.loads(p.stdout).get("format", {}).get("duration", 0) or 0) + except (ValueError, json.JSONDecodeError): + return 0.0 + + +def probe_frame_rate(path: Path) -> str | None: + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "stream=avg_frame_rate", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ) + return p.stdout.strip() or None + + +def probe_size(path: Path) -> tuple[int, int] | None: + """探测成片宽×高。overlay 模式输出尺寸跟底视频走,spec 断言要用。""" + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ) + out = p.stdout.strip() + if not out: + return None + try: + w, h = out.split(",") + return (int(w), int(h)) + except (ValueError, IndexError): + return None + + +def fit_video_blur_pad(src: Path, dst: Path, w: int, h: int, fps: float, + crf: str, preset: str, threads: str, nice: str | None, + start: float = 0.0, duration: float | None = None) -> Path: + """竖版/异规格视频入横屏管线:模糊底放大裁切 + 前景等比居中叠加(v4 VF_PROD 已验证)。 + + start/duration 同时完成裁段(-ss/-t),产物与帧序列等长。 + """ + if dst.is_file() and dst.stat().st_size > 0: + return dst + fg_h = h - 100 # 前景略小于画幅,四周露模糊底 + vf = (f"split=2[bg][fg];" + f"[bg]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},gblur=sigma=28[bg2];" + f"[fg]scale=-2:{fg_h}[fg2];" + f"[bg2][fg2]overlay=(W-w)/2:(H-h)/2,setsar=1,fps={fps},format=yuv420p") + cmd = (["nice", "-n", nice] if nice else []) + ["ffmpeg", "-y", "-v", "error"] + if start > 0: + cmd += ["-ss", str(start)] + cmd += ["-i", str(src)] + if duration is not None: + cmd += ["-t", f"{duration:.3f}"] + cmd += ["-filter_complex", vf, + "-an", "-c:v", "libx264", "-crf", crf, "-preset", preset, "-threads", threads, + str(dst)] + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + raise MgError(f"底视频归一失败 (rc={p.returncode}): {p.stderr[:500]}") + return dst diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_templates.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_templates.py new file mode 100644 index 00000000..b995d87e --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/_mg_templates.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +"""_mg_templates — motion-graphics 四件套高阶模板(非子命令,wrapper 不暴露)。 + +收编自 three-year-search v4 已验证组件(render_v4.py s17/s18/s20 + _v4_product.py): + dimension_grid DNA 多维框架逐个点亮 + 数据流粒子 + 进度条(原 s17_framework) + scroll_cards 卡组横向滚动 + 激活卡放大脉冲(原 s18_core) + crew_panel 角色照片卡错峰上浮入位 + 落定辉光(原 crew_panel_anim) + rec_highlight 录屏动态圈选:聚光暗角 + 呼吸描边 + 四角标 + 标签 chip(原 rec_highlight) + +每个模板 = (prepare, draw, mode): + prepare(params, ctx) → 校验 + 补默认值后的 params(缺必填项抛 MgError) + draw(img, t, p, ctx) → 在帧上绘制(t 为当前秒;img 为 RGBA) + mode: "frame" = 画满整帧(暗底/图片底);"overlay" = 只画覆盖层(视频底透出) +ctx: {"w","h","fps","dur","fonts","lib","state","spec_dir"},state 跨帧缓存。 +""" + +from __future__ import annotations + +import math + +from PIL import Image, ImageDraw + +import _mg_lib as L + +TEMPLATES: dict[str, tuple] = {} # name → (prepare, draw, mode),文件尾注册 + + +# ============================================================ dimension_grid +def prepare_dimension_grid(params: dict, ctx: dict) -> dict: + dims = params.get("dims") + if not isinstance(dims, list) or not dims or not all(isinstance(d, str) for d in dims): + raise L.MgError("dimension_grid 需要非空字符串列表 params.dims(如 DNA 十维名)") + p = { + "title": params.get("title", ""), + "subtitle": params.get("subtitle", ""), + "dims": dims, + "node_label": params.get("node_label", ""), + "cols": int(params.get("cols", 5)), + "accent": L.parse_color(params.get("accent", "GREEN")), + "dot_color": L.parse_color(params.get("dot_color", "CYAN")), + "card_w": int(params.get("card_w", 300)), + "card_h": int(params.get("card_h", 110)), + "gap": int(params.get("gap", 10)), + "row_gap": int(params.get("row_gap", 180)), + "y_top": int(params.get("y_top", 350)), + "first_at": float(params.get("first_at", 0.5)), + "stagger": float(params.get("stagger", 0.2)), + "progress": bool(params.get("progress", True)), + } + if p["cols"] < 1 or p["cols"] > len(dims): + p["cols"] = max(1, min(p["cols"], len(dims))) + grid_w = p["cols"] * p["card_w"] + (p["cols"] - 1) * p["gap"] + if grid_w > ctx["w"]: + raise L.MgError( + f"dimension_grid 网格总宽 {grid_w}px 超出画幅 {ctx['w']}px" + f"——减 cols/card_w/gap 或加大 spec.width") + return p + + +def draw_dimension_grid(img: Image.Image, t: float, p: dict, ctx: dict) -> None: + w, h, fonts = ctx["w"], ctx["h"], ctx["fonts"] + d = ImageDraw.Draw(img) + accent, cw, ch = p["accent"], p["card_w"], p["card_h"] + if p["title"]: + d.text((w // 2, 150), p["title"], font=fonts.get("bold", 60), + fill=L.with_alpha(L.PALETTE["WHITE"], 255), anchor="mm") + if p["subtitle"]: + d.text((w // 2, 230), p["subtitle"], font=fonts.get("med", 40), + fill=L.with_alpha(L.PALETTE["MUTED"], 255), anchor="mm") + + # 网格布局:cols 列居中,行数由 dims 数量决定 + cols = p["cols"] + pitch = cw + p["gap"] + rows = math.ceil(len(p["dims"]) / cols) + grid_w = cols * cw + (cols - 1) * p["gap"] + x0 = (w - grid_w) // 2 + node = (w // 2, p["y_top"] + (rows - 1) * (ch + p["row_gap"]) + ch + 130) + L.paste_glow(img, node, 700, accent, 50) + d = ImageDraw.Draw(img) + + for k, name in enumerate(p["dims"]): + r, c = divmod(k, cols) + # 末行不满 cols 时居中 + row_n = min(cols, len(p["dims"]) - r * cols) + row_x0 = (w - (row_n * cw + (row_n - 1) * p["gap"])) // 2 + x = row_x0 + c * pitch + y = p["y_top"] + r * (ch + p["row_gap"]) + t0 = p["first_at"] + k * p["stagger"] + lit = t >= t0 + 0.25 + prog_in = L.ease_out_back(L.seg(t, t0, t0 + 0.25)) + a = int(255 * L.seg(t, t0, t0 + 0.2)) + bx = [x, y, x + cw, y + ch] + if lit: + d.rounded_rectangle(bx, radius=20, fill=(24, 44, 40, 235), + outline=accent + (255,), width=3) + d.text((x + cw / 2, y + ch / 2), name, font=fonts.get("bold", 48), + fill=(235, 255, 244, 255), anchor="mm") + # 数据流:点从卡片底边流向中心节点 + flow = ((t - t0 - 0.25) * 0.55) % 1.0 + sx, sy = x + cw / 2, y + ch + px = L.lerp(sx, node[0], flow) + py = L.lerp(sy, node[1], flow) + d.ellipse([px - 5, py - 5, px + 5, py + 5], fill=p["dot_color"] + (220,)) + else: + al = int(90 * prog_in) if prog_in > 0 else 70 + d.rounded_rectangle(bx, radius=20, outline=(90, 110, 140, al), width=2) + d.text((x + cw / 2, y + ch / 2), name, font=fonts.get("bold", 48), + fill=(120, 135, 158, a if a > 0 else 150), anchor="mm") + + if p["node_label"]: + d.text(node, p["node_label"], font=fonts.get("bold", 50), + fill=(235, 255, 244, 255), anchor="mm") + if p["progress"]: + bar_w = min(800, w - 400) + bx0 = (w - bar_w) // 2 + prog = L.seg(t, p["first_at"], ctx["dur"] - 0.4) + d.rounded_rectangle([bx0, h - 80, bx0 + bar_w, h - 68], radius=6, fill=(40, 52, 74, 255)) + if prog > 0: + d.rounded_rectangle([bx0, h - 80, bx0 + int(bar_w * prog), h - 68], + radius=6, fill=accent + (255,)) + + +# ============================================================ scroll_cards +def _wrap_sub(sub, max_chars: int) -> list[str]: + if isinstance(sub, list): + return [str(s) for s in sub] + s = str(sub or "") + return [s[i:i + max_chars] for i in range(0, len(s), max_chars)] or [""] + + +def prepare_scroll_cards(params: dict, ctx: dict) -> dict: + cards = params.get("cards") + if not isinstance(cards, list) or not cards: + raise L.MgError("scroll_cards 需要非空 params.cards([{name, sub}, ...])") + for c in cards: + if not isinstance(c, dict) or not c.get("name"): + raise L.MgError(f"scroll_cards 卡片须含 name: {c!r}") + p = { + "title": params.get("title", ""), + "cards": cards, + "footer": params.get("footer", ""), + "active_label": params.get("active_label", ""), + "scroll_speed": float(params.get("scroll_speed", 36)), + "cycle": float(params.get("cycle", 1.35)), + "card_w": int(params.get("card_w", 360)), + "card_h": int(params.get("card_h", 520)), + "pitch": int(params.get("pitch", 400)), + "y": int(params.get("y", 620)), + "accent": L.parse_color(params.get("accent", "CYAN")), + "active_scale": float(params.get("active_scale", 1.07)), + "sub_max_chars": int(params.get("sub_max_chars", 11)), + } + if p["cycle"] <= 0: + raise L.MgError("scroll_cards params.cycle 必须 > 0") + return p + + +def draw_scroll_cards(img: Image.Image, t: float, p: dict, ctx: dict) -> None: + w, fonts = ctx["w"], ctx["fonts"] + d = ImageDraw.Draw(img) + accent = p["accent"] + if p["title"]: + d.text((w // 2, 140), p["title"], font=fonts.get("bold", 58), + fill=L.with_alpha(L.PALETTE["WHITE"], 255), anchor="mm") + scroll = p["scroll_speed"] * t + active = int(t / p["cycle"]) % len(p["cards"]) + cw, ch = p["card_w"], p["card_h"] + for k, card in enumerate(p["cards"]): + x = 210 + k * p["pitch"] - scroll + if x > w + cw or x + cw < -cw: + continue + is_act = (k == active) + sc = p["active_scale"] if is_act else 1.0 + cw2, ch2 = int(cw * sc), int(ch * sc) + cx, cy = x + cw / 2, p["y"] + bx = [cx - cw2 / 2, cy - ch2 / 2, cx + cw2 / 2, cy + ch2 / 2] + sub_lines = _wrap_sub(card.get("sub"), p["sub_max_chars"]) + if is_act: + pl = L.pulse(t, 1.8) + L.paste_glow(img, (cx, cy), 640, accent, int(46 + 30 * pl)) + d = ImageDraw.Draw(img) + d.rounded_rectangle(bx, radius=30, fill=(20, 40, 52, 242), + outline=L.with_alpha(accent, 160 + 90 * pl), width=5) + d.text((cx, cy - 130), card["name"], font=fonts.get("black", 84), + fill=(235, 250, 255, 255), anchor="mm") + y_sub = cy + 40 + for ln in sub_lines[:2]: + d.text((cx, y_sub), ln, font=fonts.get("med", 36), + fill=(180, 196, 216, 255), anchor="mm") + y_sub += 70 + if p["active_label"]: + d.text((cx, cy + 190), p["active_label"], font=fonts.get("med", 34), + fill=accent + (255,), anchor="mm") + else: + d.rounded_rectangle(bx, radius=30, fill=(20, 28, 46, 230), + outline=(70, 88, 118, 200), width=3) + d.text((cx, cy - 110), card["name"], font=fonts.get("bold", 66), + fill=(140, 156, 180, 255), anchor="mm") + y_sub = cy + 60 + for ln in sub_lines[:2]: + d.text((cx, y_sub), ln, font=fonts.get("reg", 32), + fill=(120, 134, 156, 255), anchor="mm") + y_sub += 56 + if p["footer"]: + d.text((w // 2, ctx["h"] - 80), p["footer"], font=fonts.get("med", 38), + fill=(160, 176, 198, 255), anchor="mm") + + +# ============================================================ crew_panel +def prepare_crew_panel(params: dict, ctx: dict) -> dict: + roles = params.get("roles") + if not isinstance(roles, list) or not roles: + raise L.MgError("crew_panel 需要非空 params.roles([{title, sub, accent, photo?}, ...])") + for r in roles: + if not isinstance(r, dict) or not r.get("title"): + raise L.MgError(f"crew_panel 角色须含 title: {r!r}") + if r.get("photo"): + from pathlib import Path + pp = Path(r["photo"]) + # 相对路径按 spec 所在目录解析 + r["photo"] = pp if pp.is_absolute() else Path(ctx["spec_dir"]) / pp + if not r["photo"].is_file(): + raise L.MgError(f"crew_panel 角色照片不存在: {r['photo']}") + p = { + "title": params.get("title", ""), + "roles": roles, + "footer": params.get("footer", ""), + "card_w": int(params.get("card_w", 380)), + "card_h": int(params.get("card_h", 440)), + "gap": int(params.get("gap", 40)), + "y": int(params.get("y", 330)), + "first_at": float(params.get("first_at", 0.35)), + "stagger": float(params.get("stagger", 0.45)), + "enter_dur": float(params.get("enter_dur", 0.55)), + "rise": int(params.get("rise", 130)), + "glow": bool(params.get("glow", True)), + } + total_w = p["card_w"] * len(roles) + p["gap"] * (len(roles) - 1) + if total_w > ctx["w"]: + raise L.MgError( + f"crew_panel 卡片总宽 {total_w}px 超出画幅 {ctx['w']}px" + f"——减 card_w/gap/roles 数量或加大 spec.width") + return p + + +def _role_card(r: dict, cw: int, chh: int, fonts) -> Image.Image: + """单张角色卡 RGBA(照片圆裁在上、标题与副行文在下)——静态部分只画一次。""" + accent = L.parse_color(r.get("accent", "CYAN")) + img = Image.new("RGBA", (cw, chh), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([0, 0, cw - 1, chh - 1], radius=28, fill=(22, 30, 52, 235), + outline=accent + (255,), width=3) + d.rounded_rectangle([28, 30, cw - 28, 44], radius=7, fill=accent + (220,)) + subs = r.get("sub") if isinstance(r.get("sub"), list) else ([r["sub"]] if r.get("sub") else []) + if r.get("photo"): + ps = 160 + ph = L.photo_circle(r["photo"], ps) + ring = Image.new("RGBA", (ps + 12, ps + 12), (0, 0, 0, 0)) + ImageDraw.Draw(ring).ellipse([0, 0, ps + 11, ps + 11], outline=accent + (255,), width=4) + px = (cw - ps) // 2 + L.safe_composite(img, ph, px, 64) + L.safe_composite(img, ring, px - 6, 58) + d = ImageDraw.Draw(img) + d.text((cw // 2, 274), r["title"], font=fonts.get("bold", 50), + fill=(240, 246, 255, 255), anchor="mm") + y = 326 + else: + d.text((cw // 2, 150), r["title"], font=fonts.get("bold", 56), + fill=(240, 246, 255, 255), anchor="mm") + y = 260 + for ln in subs: + d.text((cw // 2, y), str(ln), font=fonts.get("med", 34), + fill=(168, 182, 205, 255), anchor="mm") + y += 52 + return img + + +def draw_crew_panel(img: Image.Image, t: float, p: dict, ctx: dict) -> None: + w, fonts = ctx["w"], ctx["fonts"] + state = ctx["state"] + if "cards" not in state: # 静态卡只渲染一次 + state["cards"] = [_role_card(r, p["card_w"], p["card_h"], fonts) for r in p["roles"]] + cards = state["cards"] + cw, gap = p["card_w"], p["gap"] + total_w = cw * len(cards) + gap * (len(cards) - 1) + x0 = (w - total_w) // 2 + L.paste_glow(img, (w // 2, 190), 900, L.PALETTE["GREEN"], 60) + d = ImageDraw.Draw(img) + if p["title"]: + L.text_shadow(d, (w // 2, 190), p["title"], fonts.get("bold", 64)) + for ci, card in enumerate(cards): + t0 = p["first_at"] + ci * p["stagger"] + prog = L.ease_out_back(L.seg(t, t0, t0 + p["enter_dur"])) + a = int(255 * L.seg(t, t0, t0 + 0.35)) + cy = p["y"] + int((1 - prog) * p["rise"]) + ccx = x0 + ci * (cw + gap) + cw // 2 + if p["glow"] and t > t0 + p["enter_dur"]: + pl = L.pulse(t - t0 - p["enter_dur"], 1.1) + L.paste_glow(img, (ccx, cy + p["card_h"] // 2), 560, + L.PALETTE["CYAN"], int(30 + 26 * pl)) + cv = card.copy() + if a < 255: + al = cv.getchannel("A").point(lambda v: v * a // 255) + cv.putalpha(al) + L.safe_composite(img, cv, int(x0 + ci * (cw + gap)), int(cy)) + if p["footer"]: + d = ImageDraw.Draw(img) + dur = ctx["dur"] + fa = int(255 * L.seg(t, dur - 1.35, dur - 0.75)) + if fa > 0: + d.text((w // 2, ctx["h"] - 200), p["footer"], font=fonts.get("med", 46), + fill=(200, 214, 232, fa), anchor="mm") + + +# ============================================================ rec_highlight +def prepare_rec_highlight(params: dict, ctx: dict) -> dict: + zones = params.get("zones") + if not isinstance(zones, list) or not zones: + raise L.MgError("rec_highlight 需要非空 params.zones([[x0,y0,x1,y1], ...])") + for z in zones: + if not (isinstance(z, list) and len(z) == 4): + raise L.MgError(f"rec_highlight zone 须为 [x0,y0,x1,y1]: {z!r}") + p = { + "zones": [[float(v) for v in z] for z in zones], + "label": params.get("label", ""), + "zone_color": L.parse_color(params.get("zone_color", "CYAN")), + "corner_color": L.parse_color(params.get("corner_color", "GREEN")), + "dim": int(params.get("dim", 110)), + "move_dur": float(params.get("move_dur", 0.5)), + "radius": int(params.get("radius", 26)), + "cycle": params.get("cycle", "auto"), + } + return p + + +def draw_rec_highlight(img: Image.Image, t: float, p: dict, ctx: dict) -> None: + """覆盖层绘制:聚光暗角(圆角洞)+ 呼吸描边 + 四角标 + 标签 chip;zones 依次移动。""" + w, h, fonts = ctx["w"], ctx["h"], ctx["fonts"] + dur = ctx["dur"] + zones = p["zones"] + seg_len = (dur if p["cycle"] == "auto" else float(p["cycle"])) / len(zones) + if seg_len <= 0: + seg_len = dur / len(zones) + zi = min(int(t / seg_len), len(zones) - 1) + mv = L.ease_out_cubic(L.seg(t, zi * seg_len, zi * seg_len + p["move_dur"])) + x0, y0, x1, y1 = zones[zi] + if zi > 0: + px0, py0, px1, py1 = zones[zi - 1] + cx = L.lerp((px0 + px1) / 2, (x0 + x1) / 2, mv) + cy = L.lerp((py0 + py1) / 2, (y0 + y1) / 2, mv) + wq = L.lerp(px1 - px0, x1 - x0, mv) + hq = L.lerp(py1 - py0, y1 - y0, mv) + else: + cx, cy, wq, hq = (x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0 + box = [cx - wq / 2, cy - hq / 2, cx + wq / 2, cy + hq / 2] + pl = L.pulse(t, 1.6) + + # 暗角遮罩:洞外压暗,洞内透明 + mask = Image.new("L", (w, h), p["dim"]) + ImageDraw.Draw(mask).rounded_rectangle(box, radius=p["radius"], fill=0) + dim_layer = Image.new("RGBA", (w, h), (4, 8, 16, 255)) + dim_layer.putalpha(mask) + img.alpha_composite(dim_layer) + + d = ImageDraw.Draw(img) + lw = 5 + int(2 * pl) + d.rounded_rectangle(box, radius=p["radius"], + outline=L.with_alpha(p["zone_color"], 150 + 90 * pl), width=lw) + tick = 34 + col = p["corner_color"] + (255,) + for bx, by, dx, dy in [(box[0], box[1], 1, 1), (box[2], box[1], -1, 1), + (box[0], box[3], 1, -1), (box[2], box[3], -1, -1)]: + d.line([(bx, by), (bx + dx * tick, by)], fill=col, width=6) + d.line([(bx, by), (bx, by + dy * tick)], fill=col, width=6) + if p["label"]: + f = fonts.get("bold", 34) + tw = d.textlength(p["label"], font=f) + lx, ly = box[0], box[1] - 54 + d.rounded_rectangle([lx, ly - 8, lx + tw + 34, ly + 40], radius=12, + fill=p["corner_color"] + (235,)) + d.text((lx + 17, ly + 15), p["label"], font=f, + fill=L.PALETTE["INK"] + (255,), anchor="lm") + + +# ============================================================ 注册 +TEMPLATES["dimension_grid"] = (prepare_dimension_grid, draw_dimension_grid, "frame") +TEMPLATES["scroll_cards"] = (prepare_scroll_cards, draw_scroll_cards, "frame") +TEMPLATES["crew_panel"] = (prepare_crew_panel, draw_crew_panel, "frame") +TEMPLATES["rec_highlight"] = (prepare_rec_highlight, draw_rec_highlight, "overlay") diff --git a/crews/content-producer/skills/video-producer/scripts/add-silent-audio.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/add-silent-audio.py similarity index 100% rename from crews/content-producer/skills/video-producer/scripts/add-silent-audio.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/add-silent-audio.py diff --git a/crews/content-producer/skills/video-producer/scripts/assemble.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py similarity index 70% rename from crews/content-producer/skills/video-producer/scripts/assemble.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py index 7dc5e10d..339d40d7 100644 --- a/crews/content-producer/skills/video-producer/scripts/assemble.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/assemble.py @@ -8,11 +8,20 @@ Usage: python3 scripts/assemble.py [--transition hard|fade|dissolve|xfade] [--width 1080] [--fps 30] + # 显式段清单(手写管线/非常规命名产物,绕开目录发现约定)+ 拼接后帧率断言 + 逐段时长校验 + python3 scripts/assemble.py --manifest render/segments.json \ + --verify-fps 25 --expect-durations slots/shotdur.json + 入:project_dir/render/ 各 shot-NN/gen*.mp4(段已就绪,assemble 不再切段) + 或 --manifest 显式有序段清单(JSON 列表,条目为路径字符串或 {"name","path"}, + 路径相对 project_dir 或绝对;name 缺省取文件 stem,stem 为 clip 时取父目录名) 出:project_dir/video.mp4(按序拼接的成片) 可选归一化:段尺寸/帧率不一时传 --width/--fps 统一(scale + pad 16:9 + sar + fps)。 段就绪约定:render/shot-NN/ 下应有 gen*.mp4 或 multi-best*.mp4,assemble 自动识别变体取最新。 +守卫断言:--verify-fps N 拼接后断言 avg_frame_rate==N/1;--expect-durations plan.json +逐段实测时长 vs 计划 ±容差(--duration-tolerance,默认 0.12s),违反即非零退出打印明细。 +plan.json 兼容三种形态:[{"id","dur"}...] / {"segments":[...]} / {"beats":[...]}(shotdur.json 直用)。 """ import argparse @@ -222,6 +231,105 @@ def probe_video(path: Path) -> tuple[int, int, int] | None: return None +def probe_frame_rate(path: Path) -> str | None: + """探测视频流 avg_frame_rate 原始串(如 "25/1"、"30000/1001")。无视频流返回 None。""" + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "stream=avg_frame_rate", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ) + out = p.stdout.strip() + return out or None + + +def load_manifest(project: Path, manifest_path: Path) -> list[tuple[str, Path]]: + """显式有序段清单:条目为路径字符串或 {"name","path"};相对路径挂 project_dir。""" + if not manifest_path.is_file(): + die(f"manifest 不存在: {manifest_path}") + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"manifest 不是合法 JSON: {manifest_path}({e})") + if not isinstance(raw, list) or not raw: + die("manifest 必须是非空 JSON 列表(路径字符串或 {name, path} 对象)") + segments: list[tuple[str, Path]] = [] + for i, entry in enumerate(raw): + if isinstance(entry, str): + name, path = None, entry + elif isinstance(entry, dict) and "path" in entry: + name, path = entry.get("name"), entry["path"] + else: + die(f"manifest[{i}] 条目须为路径字符串或 {{name, path}}: {entry!r}") + p = Path(path) + p = p if p.is_absolute() else project / p + if not p.is_file(): + die(f"manifest[{i}] 段文件不存在: {p}") + if not name: + name = p.parent.name if p.stem == "clip" else p.stem + segments.append((str(name), p)) + return segments + + +def load_duration_plan(plan_path: Path) -> dict[str, float]: + """时长计划:兼容 [{"id","dur"}...] / {"segments":[...]} / {"beats":[...]}。""" + if not plan_path.is_file(): + die(f"时长计划不存在: {plan_path}") + try: + raw = json.loads(plan_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"时长计划不是合法 JSON: {plan_path}({e})") + if isinstance(raw, dict): + raw = raw.get("segments") or raw.get("beats") + if not isinstance(raw, list): + die("时长计划 dict 形态须含 segments 或 beats 列表") + if not isinstance(raw, list) or not raw: + die("时长计划必须是非空列表 [{id, dur}, ...]") + plan: dict[str, float] = {} + for entry in raw: + if not isinstance(entry, dict) or "id" not in entry or "dur" not in entry: + die(f"时长计划条目须含 id 与 dur: {entry!r}") + plan[str(entry["id"])] = float(entry["dur"]) + return plan + + +def check_expect_durations(segments: list[tuple[str, Path]], plan: dict[str, float], + tolerance: float) -> None: + """逐段实测时长 vs 计划 ± 容差。收集全部违例后统一退出,不只报第一处。""" + violations: list[str] = [] + checked = 0 + for name, path in segments: + if name not in plan: + print(f"[warn] 段 {name} 不在时长计划中,跳过校验") + continue + actual = probe_duration(path) + expect = plan[name] + checked += 1 + if abs(actual - expect) > tolerance: + violations.append( + f"{name}: 实测 {actual:.3f}s vs 计划 {expect:.3f}s(偏差 {actual - expect:+.3f}s > ±{tolerance}s)" + ) + unused = set(plan) - {name for name, _ in segments} + for name in sorted(unused): + print(f"[warn] 时长计划里的 {name} 没有对应段(计划过期?)") + if violations: + print(f"[fail] 逐段时长校验失败 {len(violations)} 处:", file=sys.stderr) + for v in violations: + print(f" - {v}", file=sys.stderr) + sys.exit(1) + print(f"[guard] 逐段时长校验通过:{checked} 段全部在 ±{tolerance}s 内") + + +def verify_output_fps(video_out: Path, expect_fps: int) -> None: + """拼接后帧率断言:avg_frame_rate 必须等于 expect_fps/1。""" + afr = probe_frame_rate(video_out) + if afr is None: + die(f"成片无视频流,无法验帧率: {video_out}") + want = f"{expect_fps}/1" + if afr != want: + die(f"成片帧率断言失败: avg_frame_rate={afr},期望 {want}({video_out})") + print(f"[guard] 帧率断言通过:avg_frame_rate={afr}") + + def concat_hard(segments: list[tuple[str, Path]], out: Path) -> None: """hard 转场:concat demuxer 直拼。concat_list.txt 里 file 行用绝对路径,避免 cwd 解析歧义。""" list_file = out.parent / "concat_list.txt" @@ -281,7 +389,7 @@ def concat_xfade(segments: list[tuple[str, Path, float]], out: Path, transition: def main() -> None: parser = argparse.ArgumentParser(description="Stage 12 assemble 按序拼接") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--source-dir", default=None, help="段目录(默认 render/;timeline-compose 调时传 artifacts/timeline/)") parser.add_argument("--output", default="video.mp4", help="输出名(相对 project_dir,默认 video.mp4)") parser.add_argument("--transition", default="hard", choices=sorted(VALID_TRANSITIONS)) @@ -293,6 +401,14 @@ def main() -> None: help="concat 前统一音频格式(采样率/声道,默认 24000/mono,与 awk-tts 对齐)") parser.add_argument("--audio-duration", type=float, default=None, help="静音轨时长(秒,默认取视频时长对齐)") + parser.add_argument("--manifest", default=None, + help="显式有序段清单 JSON(相对 project_dir 或绝对),绕开目录发现约定") + parser.add_argument("--verify-fps", type=int, default=None, + help="拼接后断言成片 avg_frame_rate==N/1(如 25),不符非零退出") + parser.add_argument("--expect-durations", default=None, + help="逐段时长计划 JSON([{'id','dur'}] / {'segments'} / {'beats'} 三形态兼容)") + parser.add_argument("--duration-tolerance", type=float, default=0.12, + help="--expect-durations 校验容差(秒,默认 0.12)") args = parser.parse_args() project = Path(args.project_dir).resolve() @@ -300,11 +416,16 @@ def main() -> None: preset, crf = encode_opts(args.low_memory) audio_sr, audio_ch = parse_audio_format(args.audio_format) - # 收段:两种目录结构兼容 + # 收段:--manifest 显式清单优先;否则走目录发现约定 # - render/:按 shot-NN/ 子目录收,每目录取最新 gen*.mp4 / multi-best*.mp4 # - 其他目录(如 artifacts/timeline/):直接收目录下的 clip-NN.mp4 或 *.mp4,按文件名序 segments: list[tuple[str, Path]] = [] - if source_dir.name == "render": + if args.manifest: + manifest_path = Path(args.manifest) + manifest_path = manifest_path if manifest_path.is_absolute() else project / manifest_path + segments = load_manifest(project, manifest_path) + print(f"[manifest] 显式段清单 {len(segments)} 段:{manifest_path}") + elif source_dir.name == "render": # 收段:shot-NN/ 子目录 + 命名子目录(outro/、intro/ 等) # shot-NN/ 走 multi-best*.mp4 / gen*.mp4 变体识别 # 命名子目录直接收目录下的 *.mp4(按文件名序) @@ -327,10 +448,19 @@ def main() -> None: segments.append((pick.stem, pick)) if not segments: - die(f"{source_dir}/ 下无任何段(render/ 走 shot-NN/gen*.mp4;其他目录走 *.mp4),先跑 render-shot 或 timeline-compose") + die(f"{source_dir}/ 下无任何段(render/ 走 shot-NN/gen*.mp4;其他目录走 *.mp4;" + f"非常规命名产物走 --manifest 显式清单),先跑 render-shot 或 timeline-compose") + + # 逐段时长守卫(fail-fast:拼接前对源段校验,重跑命中 checkpoint 时同样生效) + if args.expect_durations: + plan_path = Path(args.expect_durations) + plan_path = plan_path if plan_path.is_absolute() else project / plan_path + check_expect_durations(segments, load_duration_plan(plan_path), args.duration_tolerance) video_out = project / args.output if video_out.is_file(): + if args.verify_fps is not None: + verify_output_fps(video_out, args.verify_fps) print(f"[checkpoint] {args.output} 已存在:{video_out}") return @@ -342,7 +472,8 @@ def main() -> None: audio_work.mkdir(parents=True, exist_ok=True) unified: list[tuple[str, Path]] = [] for idx, (name, src) in enumerate(segments, 1): - dst = audio_work / f"{idx:02d}_{name}.mp4" + # 段名可能含 /(manifest 自由命名 / 命名子目录 "outro/clip-01"),落盘名打平 + dst = audio_work / f"{idx:02d}_{name.replace('/', '-')}.mp4" vid_dur = args.audio_duration if args.audio_duration is not None else probe_duration(src) if not dst.is_file(): print(f"[auni] {name} → {dst.name} (sr={audio_sr}, ch={audio_ch})") @@ -403,6 +534,10 @@ def main() -> None: with_dur = [(n, s, probe_duration(s)) for n, s in segments] concat_xfade(with_dur, video_out, args.transition, preset, crf) + # 拼接后帧率断言(brief 常要求的守卫:混拼不同帧率源时兜底) + if args.verify_fps is not None: + verify_output_fps(video_out, args.verify_fps) + # 可选预览:截前 N 秒到 -preview.mp4,用于试听 if args.preview_duration is not None: preview_out = video_out.with_name(f"{video_out.stem}-preview{video_out.suffix}") diff --git a/crews/content-producer/skills/video-producer/scripts/asset-resolve.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py similarity index 96% rename from crews/content-producer/skills/video-producer/scripts/asset-resolve.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py index fc8d21c8..b5882bba 100644 --- a/crews/content-producer/skills/video-producer/scripts/asset-resolve.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/asset-resolve.py @@ -37,7 +37,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 8 asset-resolve") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--source", default="both", choices=["pexels", "pixabay", "both"]) parser.add_argument("--no-confirm", action="store_true", help="agent 已人核完毕,不再呈交") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/audio-mix.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/audio-mix.py similarity index 55% rename from crews/content-producer/skills/video-producer/scripts/audio-mix.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/audio-mix.py index b4d64467..222c79d5 100644 --- a/crews/content-producer/skills/video-producer/scripts/audio-mix.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/audio-mix.py @@ -17,20 +17,32 @@ --track bgm.mp3 --delay 2 --volume 0.2 \ --output mixed.mp3 --duration 30 + # BGM 首尾 fade(--fadein/--fadeout 按 --track 顺序对应,缺省补 0) + python3 scripts/audio-mix.py \ + --track narration.mp3 --delay 0 --volume 1.0 \ + --track bgm.mp3 --delay 0 --volume 0.22 --fadein 0.8 --fadeout 2.2 \ + --output mixed.wav --duration 85.6 + 参数说明: --track 音轨(可重复多次,每次跟 --delay 和 --volume) --delay 该轨起始延时(秒,默认 0) - --volume 该轨音量系数(0.0-1.0,默认 1.0;0.15 = �压到 15%) + --volume 该轨音量系数(0.0-1.0,默认 1.0;0.15 = 压到 15%) + --fadein 该轨淡入时长(秒,默认 0;从轨自身内容起点开始淡入) + --fadeout 该轨淡出时长(秒,默认 0;锚定在轨有效末端 = min(轨实测时长, --duration - delay)) --output 输出混合音频路径 - --duration 输出总时长(秒,可选;不传则取最长轨延时+时长) + --duration 输出总时长(秒,可选;不传则取最长轨延时+时长)。 + 硬上限语义:短轨 apad 补虚到该时长,超出的轨被截断(等效 atrim) -实现:ffmpeg adelay(毫秒延时)+ volume(音量)+ apad(补虚到 --duration)+ amix(叠加)。 +实现:ffmpeg afade(淡入淡出,轨本地时间轴)+ adelay(毫秒延时)+ volume(音量) ++ apad(补虚到 --duration)+ amix(叠加)。 +afade 放在 adelay 之前:fade 作用于轨自身内容,不受延时平移影响。 延时用 adelay=;--duration 时 apad=whole_dur= 把每轨补虚到总时长; amix inputs=N duration=longest dropout_transition=0 normalize=0—— normalize=0 禁用 amix 自动除以轨道数,每轨 volume 即最终音量(设 2.0 就是 2 倍,不被稀释)。 """ import argparse +import json import subprocess import sys from pathlib import Path @@ -49,6 +61,43 @@ def run(cmd: list[str]) -> subprocess.CompletedProcess: return p +def probe_duration(path: Path) -> float: + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)], + capture_output=True, text=True, + ) + if p.returncode != 0: + return 0.0 + try: + return float(json.loads(p.stdout).get("format", {}).get("duration", 0) or 0) + except (ValueError, json.JSONDecodeError): + return 0.0 + + +def fade_chain(fadein: float, fadeout: float, track: str, delay: float, + duration: float | None) -> list[str]: + """构造该轨的 afade 滤镜段(轨本地时间轴,adelay 之前生效)。 + + fadein:st=0 起淡入 fadein 秒。 + fadeout:锚定有效末端 eff = min(轨实测时长, duration - delay),st = eff - fadeout。 + """ + parts: list[str] = [] + if fadein > 0: + parts.append(f"afade=t=in:st=0:d={fadein}") + if fadeout > 0: + track_dur = probe_duration(Path(track)) + if track_dur <= 0: + die(f"--fadeout 需要实测轨时长,ffprobe 失败: {track}") + eff = track_dur + if duration is not None: + eff = min(eff, duration - delay) + st = eff - fadeout + if st < 0: + die(f"--fadeout {fadeout}s 超过轨 {Path(track).name} 的有效时长 {eff:.3f}s") + parts.append(f"afade=t=out:st={st:.3f}:d={fadeout}") + return parts + + def main() -> None: parser = argparse.ArgumentParser(description="audio-mix 多轨混音") parser.add_argument( @@ -63,21 +112,38 @@ def main() -> None: "--volume", action="append", type=float, default=None, help="该轨音量系数(0.0-1.0,默认 1.0;按 --track 顺序对应)", ) + parser.add_argument( + "--fadein", action="append", type=float, default=None, + help="该轨淡入时长(秒,默认 0;按 --track 顺序对应)", + ) + parser.add_argument( + "--fadeout", action="append", type=float, default=None, + help="该轨淡出时长(秒,默认 0;锚定轨有效末端,按 --track 顺序对应)", + ) parser.add_argument("--output", required=True, help="输出混合音频路径") - parser.add_argument("--duration", type=float, default=None, help="输出总时长(秒,可选)") + parser.add_argument("--duration", type=float, default=None, + help="输出总时长(秒,可选;硬上限:短轨补虚、超出轨截断)") args = parser.parse_args() tracks = args.track n = len(tracks) - # delay/volume 按 track 顺序对应,缺省补默认值 + # delay/volume/fadein/fadeout 按 track 顺序对应,缺省补默认值 delays = args.delay if args.delay else [] delays += [0.0] * (n - len(delays)) volumes = args.volume if args.volume else [] volumes += [1.0] * (n - len(volumes)) + fadeins = args.fadein if args.fadein else [] + fadeins += [0.0] * (n - len(fadeins)) + fadeouts = args.fadeout if args.fadeout else [] + fadeouts += [0.0] * (n - len(fadeouts)) if len(delays) != n or len(volumes) != n: die(f"--delay / --volume 数量须等于 --track 数量({n})") + if len(fadeins) != n or len(fadeouts) != n: + die(f"--fadein / --fadeout 数量须等于 --track 数量({n})") + if any(f < 0 for f in fadeins + fadeouts): + die("--fadein / --fadeout 必须 >= 0") # 校验各轨文件存在 for t in tracks: @@ -88,19 +154,21 @@ def main() -> None: dst.parent.mkdir(parents=True, exist_ok=True) # ffmpeg filter_complex 构造: - # [i:a]adelay=,volume=,apad=whole_dur=[a] 各轨延时+音量+补虚到总时长 + # [i:a]afade...,adelay=,volume=,apad=whole_dur=[a] 各轨淡入出+延时+音量+补虚 # [a0][a1]...[aN]amix=inputs=N:duration=longest:dropout_transition=0:normalize=0[aout] # normalize=0 禁用 amix 自动除以轨道数,每轨 volume 即最终音量(设 2.0 就是 2 倍,不被稀释) # apad=whole_dur= 把每轨补虚到 --duration 总时长,避免短轨被截、输出时长不足 + # afade 在 adelay 前:fade 作用于轨本地时间轴,不被延时平移 inputs: list[str] = [] filter_parts: list[str] = [] dur_ms = int(args.duration * 1000) if args.duration is not None else None - for i, (delay_s, vol) in enumerate(zip(delays, volumes)): + for i, (delay_s, vol, fi, fo) in enumerate(zip(delays, volumes, fadeins, fadeouts)): inputs.extend(["-i", tracks[i]]) delay_ms = int(delay_s * 1000) - parts = [] + parts = fade_chain(fi, fo, tracks[i], delay_s, args.duration) if delay_ms > 0: - parts.append(f"adelay={delay_ms}") + # all=1:所有声道统一延时(否则立体声轨只延左声道,声像错位) + parts.append(f"adelay={delay_ms}:all=1") if vol != 1.0: parts.append(f"volume={vol}") if dur_ms is not None: @@ -140,8 +208,9 @@ def main() -> None: print(f"[done] 混合音频已落:{dst}") print(f" - {n} 轨混入") - for i, (t, d, v) in enumerate(zip(tracks, delays, volumes)): - print(f" [{i}] {Path(t).name} delay={d}s volume={v}") + for i, (t, d, v, fi, fo) in enumerate(zip(tracks, delays, volumes, fadeins, fadeouts)): + fades = f" fadein={fi}s fadeout={fo}s" if (fi > 0 or fo > 0) else "" + print(f" [{i}] {Path(t).name} delay={d}s volume={v}{fades}") if args.duration is not None: print(f" - 总时长限制:{args.duration}s") diff --git a/crews/content-producer/scripts/burn-srt.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/burn-srt.py similarity index 94% rename from crews/content-producer/scripts/burn-srt.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/burn-srt.py index f3fc0913..387aa73c 100644 --- a/crews/content-producer/scripts/burn-srt.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/burn-srt.py @@ -5,7 +5,7 @@ 平台播放器可开关)不同——硬烧适合"平台不支持外挂字幕"或"想保证画面字 一定显示"的场景。 -⚠️ 可选步骤,不是必跑。Content Producer 的 AGENTS.md 工作流默认不烧字幕 +⚠️ 可选步骤,不是必跑。通用制作流程默认不烧字幕 (assemble.py / exportMp4 都不烧);**仅当用户明确说"要字幕"/"烧字幕"/ "hardcode subtitles"时才跑**。 @@ -18,9 +18,9 @@ - 字幕样式由 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 + video-producer burn-srt + video-producer burn-srt --output + video-producer burn-srt --font-name "Noto Sans CJK SC" --font-size 24 Exit codes: 0 ok,字幕烧完 diff --git a/crews/content-producer/skills/video-producer/scripts/character-register.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py similarity index 96% rename from crews/content-producer/skills/video-producer/scripts/character-register.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py index f861e360..943cf7c5 100644 --- a/crews/content-producer/skills/video-producer/scripts/character-register.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/character-register.py @@ -29,7 +29,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 6 character-register") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/clip-trim.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/clip-trim.py new file mode 100644 index 00000000..b14e1cc6 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/clip-trim.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""clip-trim — 精确切素材:入点/出点/倍速/归一化/调色/多窗拼接/定帧缓推,视频音频分别处理。 + +原子工具,不写死 Workflow。agent 按 SKILL.md 场景化组合调用。 + +Usage: + # 切视频段(0.5s 入,2.3s 出,1.5x 倍速) + python3 scripts/clip-trim.py --input shot.mp4 --output clip.mp4 --start 0.5 --end 2.3 --speed 1.5 + + # 只切音频段(30s 入,35s 出,原速) + python3 scripts/clip-trim.py --input narration.mp3 --output clip.mp3 --start 30 --end 35 + + # 切片同时归一化到 1920x1080@25(scale+pad+sar+fps 一步,24fps 源混拼前必做) + python3 scripts/clip-trim.py --input src.mkv --output clip.mp4 --start 12 --end 15.2 \ + --normalize 1920x1080@25 + + # 一镜多窗:同源切两段按序 concat copy(窗口格式 start:length,逗号分隔) + python3 scripts/clip-trim.py --input src.mkv --output clip.mp4 \ + --windows "12.0:3.2,44.5:1.4" --normalize 1920x1080@25 + + # 暖色调色(预设色板,不做自由调色) + python3 scripts/clip-trim.py --input src.mkv --output clip.mp4 --start 8 --end 12 --grade warm + + # 定帧缓推(Ken Burns):视频源在 --start 取帧,或图片输入直接推 + python3 scripts/clip-trim.py --input src.mkv --output clip.mp4 --start 528 --zoompan 1.08 \ + --duration 4.2 --normalize 1920x1080@25 + python3 scripts/clip-trim.py --input still.png --output clip.mp4 --zoompan 1.08 --duration 4.2 + +参数说明: + --input 输入素材路径(视频、音频或图片) + --output 输出路径 + --start 入点(秒,默认 0;--zoompan 配视频源时 = 取帧时刻) + --end 出点(秒,默认 = 输入全长;--windows/--zoompan 模式下忽略) + --speed 倍速(默认 1.0;2.0 = 2x,0.5 = 0.5x) + --sync-audio 视频倍速时同步音频倍速(用 atempo filter;超出 0.5-2.0 范围链式串联) + --duration 输出时长(秒):图片输入必填;--zoompan 配视频源必填;其余模式默认 (end-start)/speed + --normalize WxH@FPS 切片时归一化:scale(保比缩小)+pad(黑边)+setsar=1+fps 一步 + --grade warm|neutral 预设调色(warm = colorbalance 暖调 + vignette 暗角;neutral = 不调) + --windows "start:length,..." 一镜多窗:按序切多段再 concat copy(分段产物落 .parts/,存在即跳过) + --zoompan END_ZOOM 定帧缓推:END_ZOOM 为结束放大倍率(如 1.08 = 推到 108%),中心锚点匀速推近 + --low-load 低载编码(nice 19 + preset veryfast + crf 18 + threads 2,共享机器上的 brief 常用约束) + --force 清 .parts/ 与 .zoompan/ 工作目录强制重切(源换内容但参数未变时用) + +视频倍速原理:setpts=PTS/speed 改时间戳;音频 atempo=speed 改播放速率。 +倍速后时长 = (end - start) / speed。切片起点走 input seek(源时间语义),倍速不改变入点位置。 +checkpoint:--windows 分段与 --zoompan 定帧落工作目录(存在即跳过),带参数指纹—— +windows/起点/归一/调色参数一变自动清场重切,不会静默复用陈旧内容。 +""" + +import argparse +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} +GRADE_PRESETS = { + # v4 已验证的暖调预设:colorbalance 暖移 + 轻暗角 + "warm": "colorbalance=rm=0.22:gm=0.07:bm=-0.18,vignette=angle=PI/5", +} +LOW_LOAD = {"preset": "veryfast", "crf": "18", "threads": "2", "nice": "19"} +NORMAL_ENCODE = {"preset": "fast", "crf": "23", "threads": None, "nice": None} +DUR_TOLERANCE = 0.12 # 输出时长 vs 期望的告警容差(秒) + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd: list[str]) -> subprocess.CompletedProcess: + print(f"[cmd] {cmd[0]} ... ({len(cmd)} args)") + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + die(f"命令失败 (rc={p.returncode}): {p.stderr[:500] or p.stdout[:500]}") + return p + + +def maybe_nice(cmd: list[str], enc: dict) -> list[str]: + return (["nice", "-n", enc["nice"]] if enc.get("nice") else []) + cmd + + +def encode_args(enc: dict) -> list[str]: + out = ["-c:v", "libx264", "-preset", enc["preset"], "-crf", enc["crf"]] + if enc.get("threads"): + out += ["-threads", enc["threads"]] + return out + + +def probe_duration(path: Path) -> float: + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)], + capture_output=True, text=True, + ) + if p.returncode != 0: + return 0.0 + try: + return float(json.loads(p.stdout).get("format", {}).get("duration", 0) or 0) + except (ValueError, json.JSONDecodeError): + return 0.0 + + +def probe_size(path: Path) -> tuple[int, int] | None: + """探测宽×高(图片或视频)。失败返回 None。""" + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ) + out = p.stdout.strip() + if not out: + return None + try: + w, h = out.split(",") + return (int(w), int(h)) + except (ValueError, IndexError): + return None + + +def is_video(path: Path) -> bool: + """用 ffprobe 看是否有视频流。""" + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ) + return p.stdout.strip() == "video" + + +def atempo_chain(speed: float) -> str: + """atempo filter 链。atempo 单级范围 [0.5, 2.0],超出则链式串联。 + 例:4x → atempo=2.0,atempo=2.0;0.25x → atempo=0.5,atempo=0.5。""" + if 0.5 <= speed <= 2.0: + return f"atempo={speed}" + parts = [] + remaining = speed + while remaining > 2.0: + parts.append("atempo=2.0") + remaining /= 2.0 + while remaining < 0.5: + parts.append("atempo=0.5") + remaining /= 0.5 + parts.append(f"atempo={remaining}") + return ",".join(parts) + + +def audio_codec_args(dst: Path) -> list[str]: + """音频编码参数按输出容器选(.mp3 不能封 aac——按扩展名分流)。""" + ext = dst.suffix.lower() + if ext == ".mp3": + return ["-c:a", "libmp3lame", "-b:a", "192k"] + if ext == ".wav": + return ["-c:a", "pcm_s16le"] + if ext == ".ogg": + return ["-c:a", "libvorbis", "-b:a", "192k"] + return ["-c:a", "aac", "-b:a", "192k"] + + +def parse_normalize(raw: str) -> tuple[int, int, float]: + """'1920x1080@25' → (1920, 1080, 25.0)。""" + m = re.fullmatch(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", raw.strip()) + if not m: + die(f"--normalize 格式错误: {raw}(应为 WxH@FPS,如 1920x1080@25)") + w, h, fps = int(m.group(1)), int(m.group(2)), float(m.group(3)) + if w % 2 or h % 2: + die(f"--normalize 宽高必须为偶数(yuv420p 要求): {raw}") + if fps <= 0: + die(f"--normalize fps 必须 > 0: {raw}") + return w, h, fps + + +def parse_windows(raw: str) -> list[tuple[float, float]]: + """'12.0:3.2,44.5:1.4' → [(12.0, 3.2), (44.5, 1.4)](start:length)。""" + windows: list[tuple[float, float]] = [] + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk: + continue + m = re.fullmatch(r"(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)", chunk) + if not m: + die(f"--windows 窗口格式错误: {chunk}(应为 start:length,如 12.0:3.2)") + start, length = float(m.group(1)), float(m.group(2)) + if length <= 0: + die(f"--windows 窗口时长必须 > 0: {chunk}") + windows.append((start, length)) + if not windows: + die(f"--windows 解析不出任何窗口: {raw}") + return windows + + +def build_vf(normalize: tuple[int, int, float] | None, grade: str, + speed: float = 1.0, with_audio_pts: bool = True) -> list[str]: + """视频滤镜链:[setpts] → [scale+pad] → [grade] → setsar → fps → format。""" + parts: list[str] = [] + if with_audio_pts and speed != 1.0: + parts.append(f"setpts=PTS/{speed}") + if normalize: + w, h, fps = normalize + parts.append(f"scale={w}:{h}:force_original_aspect_ratio=decrease") + parts.append(f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black") + if grade != "neutral": + parts.append(GRADE_PRESETS[grade]) + if normalize: + w, h, fps = normalize + parts += ["setsar=1", f"fps={fps}", "format=yuv420p"] + return parts + + +def cut_once(src: Path, dst: Path, start: float, length: float, vf: list[str], + af: list[str], normalize: tuple | None, enc: dict, + keep_audio: bool) -> None: + """单次切片编码。-ss 放 -i 前(input seek,源时间语义,视频/音频流统一生效); + -t 放 -i 后限输出时长。倍速链(setpts/atempo)压缩的是滤镜后时间轴, + output seek 的 -ss 会被静默放大 speed 倍——所以起点一律走 input seek。""" + cmd = ["ffmpeg", "-y", "-ss", str(start), "-i", str(src), "-t", str(length)] + if vf: + cmd += ["-vf", ",".join(vf)] + if af: + cmd += ["-af", ",".join(af)] + cmd += encode_args(enc) + if normalize: + cmd += ["-r", str(normalize[2])] + if keep_audio: + cmd += audio_codec_args(dst) + else: + cmd += ["-an"] + cmd.append(str(dst)) + run(maybe_nice(cmd, enc)) + + +def params_fingerprint(work_dir: Path, params: dict) -> bool: + """参数指纹比对:一致返回 True(checkpoint 可用);不一致清场返回 False。 + + 调窗/调起点/调归一参数是这两个模式的日常迭代方式,光靠产物存在性做 + checkpoint 会静默复用陈旧内容(改起点时长不变,连时长告警都没有)。 + """ + fp_file = work_dir / "params.json" + current = json.dumps(params, sort_keys=True) + if fp_file.is_file() and fp_file.read_text(encoding="utf-8") == current: + return True + for stale in work_dir.glob("*"): + if stale.is_file(): + stale.unlink(missing_ok=True) + work_dir.mkdir(parents=True, exist_ok=True) + fp_file.write_text(current, encoding="utf-8") + return False + + +def mode_windows(src: Path, dst: Path, windows: list[tuple[float, float]], args, + normalize, vf: list[str], af: list[str], enc: dict) -> float: + """一镜多窗:逐窗切段(分段产物即 checkpoint,带参数指纹)→ concat demuxer copy。""" + parts_dir = dst.parent / f"{dst.stem}.parts" + parts_dir.mkdir(parents=True, exist_ok=True) + fresh = not params_fingerprint(parts_dir, { + "windows": args.windows, "speed": args.speed, + "normalize": args.normalize, "grade": args.grade, "low_load": args.low_load}) + if fresh: + print("[fingerprint] 参数变化,分段重切") + part_files: list[Path] = [] + expected = 0.0 + for i, (start, length) in enumerate(windows): + part = parts_dir / f"part{i:02d}.mp4" + out_len = length / args.speed + expected += out_len + if part.is_file() and part.stat().st_size > 0: + print(f"[checkpoint] 窗 {i} 已存在:{part}") + else: + print(f"[window {i}] start={start}s length={length}s") + cut_once(src, part, start, out_len, vf, af, normalize, enc, keep_audio=True) + part_files.append(part) + list_file = parts_dir / "concat.txt" + list_file.write_text( + "\n".join(f"file '{p.resolve()}'" for p in part_files) + "\n", encoding="utf-8") + run(maybe_nice(["ffmpeg", "-y", "-f", "concat", "-safe", "0", + "-i", str(list_file), "-c", "copy", str(dst)], enc)) + return expected + + +def mode_zoompan(src: Path, dst: Path, args, normalize, enc: dict, + is_image: bool) -> float: + """定帧缓推:视频源先取帧(图片源直接用),再 zoompan 匀速推近。""" + duration = args.duration + if duration is None or duration <= 0: + die("--zoompan 必须搭配 --duration(输出时长,秒)") + if normalize: + w, h, fps = normalize + else: + size = probe_size(src) + if not size: + die(f"无法探测输入尺寸: {src}(或显式传 --normalize WxH@FPS)") + w, h = size[0] // 2 * 2, size[1] // 2 * 2 + fps = 25.0 + nfr = max(2, int(round(duration * fps))) + + work_dir = dst.parent / f"{dst.stem}.zoompan" + work_dir.mkdir(parents=True, exist_ok=True) + fresh = not params_fingerprint(work_dir, { + "start": args.start, "zoompan": args.zoompan, + "normalize": args.normalize, "grade": args.grade}) + if fresh: + print("[fingerprint] 参数变化,重新取帧") + still = work_dir / "still.png" + if not still.is_file(): + if is_image: + # 图片先归一到目标画幅(保比 + pad),zoompan 不做拉伸 + vf = build_vf(normalize, args.grade, with_audio_pts=False) + cmd = ["ffmpeg", "-y", "-i", str(src)] + if vf: + cmd += ["-vf", ",".join(vf)] + cmd += ["-frames:v", "1", str(still)] + run(maybe_nice(cmd, enc)) + else: + vf = build_vf(normalize, args.grade, with_audio_pts=False) + cmd = ["ffmpeg", "-y", "-ss", str(args.start), "-i", str(src), "-frames:v", "1"] + if vf: + cmd += ["-vf", ",".join(vf)] + cmd += ["-q:v", "2", str(still)] + run(maybe_nice(cmd, enc)) + print(f"[still] 取帧落盘:{still}") + else: + print(f"[checkpoint] 定帧已存在:{still}") + + dz = args.zoompan - 1.0 + zp = (f"zoompan=z='1+({dz:.4f}*on/{nfr})':d={nfr}" + f":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'" + f":s={w}x{h}:fps={fps}") + cmd = ["ffmpeg", "-y", "-loop", "1", "-i", str(still), "-t", f"{duration:.3f}", + "-vf", f"{zp},format=yuv420p", "-an"] + cmd += encode_args(enc) + cmd.append(str(dst)) + run(maybe_nice(cmd, enc)) + return duration + + +def mode_static(src: Path, dst: Path, args, normalize, vf: list[str], enc: dict) -> float: + """图片输入 + --duration:静图定格成视频段(无缓推)。""" + duration = args.duration + if duration is None or duration <= 0: + die("图片输入必须给 --duration(或 --zoompan 搭配 --duration 做缓推)") + cmd = ["ffmpeg", "-y", "-loop", "1", "-i", str(src), "-t", f"{duration:.3f}"] + if vf: + cmd += ["-vf", ",".join(vf)] + cmd += encode_args(enc) + if normalize: + cmd += ["-r", str(normalize[2])] + cmd += ["-an", str(dst)] + run(maybe_nice(cmd, enc)) + return duration + + +def main() -> None: + parser = argparse.ArgumentParser(description="clip-trim 精确切素材") + parser.add_argument("--input", required=True, help="输入素材路径(视频、音频或图片)") + parser.add_argument("--output", required=True, help="输出路径") + parser.add_argument("--start", type=float, default=0.0, help="入点(秒,默认 0)") + parser.add_argument("--end", type=float, default=None, help="出点(秒,默认=输入全长)") + parser.add_argument("--speed", type=float, default=1.0, help="倍速(默认 1.0)") + parser.add_argument("--sync-audio", action="store_true", help="视频倍速时同步音频倍速") + parser.add_argument("--pre-buffer", type=float, default=0.0, + help="切段前置缓冲(秒,默认 0):实际入点提前该值,避免 -ss 切 MP3 吞首字;逻辑入点仍是原 start") + parser.add_argument("--duration", type=float, default=None, + help="输出时长(秒):图片输入与 --zoompan 配视频源时必填") + parser.add_argument("--normalize", default=None, + help="归一化目标 WxH@FPS(如 1920x1080@25):scale+pad+sar+fps 一步") + parser.add_argument("--grade", default="neutral", choices=["neutral", "warm"], + help="预设调色(warm=暖调+暗角;默认 neutral 不调)") + parser.add_argument("--windows", default=None, + help='一镜多窗 "start:length,start:length"(切多段按序 concat copy)') + parser.add_argument("--zoompan", type=float, default=None, + help="定帧缓推结束倍率(如 1.08);视频源在 --start 取帧,图片源直接推") + parser.add_argument("--low-load", action="store_true", + help="低载编码:nice 19 + veryfast + crf 18 + threads 2") + parser.add_argument("--force", action="store_true", + help="清多窗/zoompan 工作目录强制重切重取帧(源文件换内容但参数未变时用;参数变了有指纹自动重切)") + args = parser.parse_args() + + src = Path(args.input).resolve() + dst = Path(args.output).resolve() + if not src.is_file(): + die(f"输入不存在: {src}") + if args.speed <= 0: + die(f"倍速必须 > 0,收到 {args.speed}") + if args.pre_buffer < 0: + die(f"--pre-buffer 必须 >= 0,收到 {args.pre_buffer}") + if args.zoompan is not None and args.zoompan <= 1.0: + die(f"--zoompan 结束倍率必须 > 1.0(推近),收到 {args.zoompan}") + if args.windows and args.zoompan is not None: + die("--windows 与 --zoompan 不能同时用(多窗是实拍切片,缓推是定帧动画)") + + normalize = parse_normalize(args.normalize) if args.normalize else None + # 图片按扩展名判定(ffprobe 对单张图片也报 video 流,不能用 is_video 区分) + is_image = src.suffix.lower() in IMAGE_EXTS + has_video = is_image or is_video(src) + if (args.windows or args.zoompan is not None or normalize) and not has_video: + die(f"--windows/--zoompan/--normalize 只支持视频或图片输入: {src}") + + enc = dict(LOW_LOAD) if args.low_load else dict(NORMAL_ENCODE) + dst.parent.mkdir(parents=True, exist_ok=True) + if args.force: + for suffix in (".parts", ".zoompan"): + work = dst.parent / f"{dst.stem}{suffix}" + if work.is_dir(): + shutil.rmtree(work, ignore_errors=True) + print(f"[force] 已清工作目录:{work}") + + # ===== 三个特化模式 ===== + if args.windows: + if is_image or not has_video: + die(f"--windows 只支持视频输入: {src}") + windows = parse_windows(args.windows) + vf = build_vf(normalize, args.grade, speed=args.speed) + af = [atempo_chain(args.speed)] if (args.sync_audio and args.speed != 1.0) else [] + expected = mode_windows(src, dst, windows, args, normalize, vf, af, enc) + elif args.zoompan is not None: + expected = mode_zoompan(src, dst, args, normalize, enc, is_image) + elif is_image: + vf = build_vf(normalize, args.grade, with_audio_pts=False) + expected = mode_static(src, dst, args, normalize, vf, enc) + else: + # ===== 经典单窗切片(视频/音频)===== + src_dur = probe_duration(src) + end = args.end if args.end is not None else src_dur + if end <= args.start: + die(f"出点({end})必须大于入点({args.start})") + # pre-buffer:实际入点提前 N 秒(不越过 0),保留段头音频避免吞首字 + real_start = max(0.0, args.start - args.pre_buffer) + trim_dur = end - real_start + out_dur = trim_dur / args.speed + if args.duration is not None: + out_dur = args.duration # 显式时长优先(-t 卡输出) + + # -ss 走 input seek(源时间语义):倍速链压缩滤镜后时间轴,output seek 会错位 + cmd = ["ffmpeg", "-y", "-ss", str(real_start), "-i", str(src), "-t", str(out_dur)] + vf = [] + af = [] + if has_video: + vf = build_vf(normalize, args.grade, speed=args.speed) + if args.speed != 1.0 and args.sync_audio: + af.append(atempo_chain(args.speed)) + if vf: + cmd.extend(["-vf", ",".join(vf)]) + if af: + cmd.extend(["-af", ",".join(af)]) + if has_video and not is_image: + cmd.extend(encode_args(enc)) + if normalize: + cmd.extend(["-r", str(normalize[2])]) + cmd.extend(audio_codec_args(dst)) + cmd.append(str(dst)) + run(maybe_nice(cmd, enc)) + expected = out_dur + + actual_dur = probe_duration(dst) + print(f"[done] {src.name} → {dst.name}") + print(f" - 期望时长 {expected:.3f}s,实际 {actual_dur:.3f}s") + if abs(actual_dur - expected) > DUR_TOLERANCE: + print(f"[warn] 时长偏差 {actual_dur - expected:+.3f}s 超过容差 {DUR_TOLERANCE}s,检查源与参数", + file=sys.stderr) + if args.normalize: + w, h, fps = normalize + print(f" - 归一化:{w}x{h}@{fps}fps") + if args.grade != "neutral": + print(f" - 调色预设:{args.grade}") + if args.windows: + print(f" - 多窗:{args.windows}") + if args.zoompan is not None: + print(f" - 定帧缓推:zoom 1.0 → {args.zoompan}") + if args.low_load: + print(f" - 低载编码:nice {LOW_LOAD['nice']} / {LOW_LOAD['preset']} / crf {LOW_LOAD['crf']} / threads {LOW_LOAD['threads']}") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py similarity index 92% rename from crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py index 637bf68b..c9e3b049 100644 --- a/crews/content-producer/skills/video-producer/scripts/delivery-promise-lock.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/delivery-promise-lock.py @@ -33,7 +33,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 9b delivery-promise-lock") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() @@ -72,7 +72,7 @@ def main() -> None: } promise_path.write_text(json.dumps(stub, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[done] delivery-promise.json 模板已落:{promise_path}") - print(f"[next] GATE B 素材闸门:呈交 slot/素材/slide-risk/promise �摘要 → 用户批 → 跑 render-shot(Stage 10)") + print(f"[next] GATE B 素材闸门:呈交 slot/素材/slide-risk/promise 摘要 → 用户批 → 跑 render-shot(Stage 10)") if __name__ == "__main__": diff --git a/crews/content-producer/scripts/denoise.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/denoise.py similarity index 95% rename from crews/content-producer/scripts/denoise.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/denoise.py index 43e0692f..07d45938 100644 --- a/crews/content-producer/scripts/denoise.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/denoise.py @@ -5,7 +5,7 @@ 只在用户素材音质差(环境噪声大、空调嗡、键盘吱)时用——AI 生成视频的音轨 是干净的,不需要降噪。 -⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做降噪处理。 +⚠️ 可选步骤,不是必跑。通用制作流程默认不做降噪处理。 **仅当用户素材音质明显差**(用户抱怨"听不清"/"有杂音"/"噪音大", 或 review.py 报噪声指标异常)时才跑。 @@ -21,10 +21,10 @@ 干湿分离:输出 `_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 + video-producer denoise + video-producer denoise --output + video-producer denoise --method arnndn --rnn-model /path/to/model.rnn + video-producer denoise --noise-floor -40 --nr 12 Exit codes: 0 ok,降噪完成 diff --git a/crews/content-producer/scripts/duck.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py similarity index 96% rename from crews/content-producer/scripts/duck.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py index 36c39501..80f0fd43 100644 --- a/crews/content-producer/scripts/duck.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/duck.py @@ -4,7 +4,7 @@ 把 BGM 轨在旁白/对话出现时自动压低,旁白停了再放开——专业混音的标配。 只在声画同出模式(gen.py 出的片旁白+BGM 同轨)且用户要专业混音时用。 -⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做混音处理—— +⚠️ 可选步骤,不是必跑。通用制作流程默认不做混音处理—— assemble.py / normalize.py 都只碰整体响度,不动轨间电平。 **仅当用户明确说"要混音"/"做 ducking"/"BGM 压旁白"/"professional mix"时才跑**。 @@ -26,9 +26,9 @@ - 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 + video-producer duck --bgm-track audio:0 + video-producer duck --bgm-source bgm.mp3 --output mixed.mp4 + video-producer duck --threshold -25 --ratio 8 Exit codes: 0 ok,ducking 完成 diff --git a/crews/content-producer/skills/video-producer/scripts/intent-router.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py similarity index 84% rename from crews/content-producer/skills/video-producer/scripts/intent-router.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py index 0415762f..c8f673d7 100644 --- a/crews/content-producer/skills/video-producer/scripts/intent-router.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/intent-router.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage 0 — intent-router:把用户意图路由成三档脚本模板。 +"""Stage 1 — intent-router:把 Brief 意图路由成三档脚本模板。 三档(内部 key 保留英文,user-facing 表述用中文名): - narrative(故事讲述型)——重情节、有人物弧光、含旁白 → 默认 3–5 镜/场 @@ -7,9 +7,9 @@ - montage(蒙太奇剪接型)——重氛围/抽象/纯视觉 → 默认 4–7 镜无叙事 Usage: - python3 scripts/intent-router.py [--user-text "..."] [--report-file path] + video-producer intent-router [--user-text "..."] [--report-file path] -入:project_dir(output_videos// 或平台运营目录 /outputs//),可选用户原文或 viral-chaser 报告路径 +入:project_dir(CP 自建工作区 output_videos//),可选甲方原文或参考拆解报告路径 出:project_dir/script/intent.json(档位 + 主题 + 受众 + 时长目标 + 备选 + 决策理由) 产物文件存在性即 checkpoint:intent.json 已存在则打印现状退出,不重生成(用户手改后续跑)。 @@ -43,10 +43,10 @@ def detect_genre(text: str) -> tuple[str, str]: def main() -> None: - parser = argparse.ArgumentParser(description="Stage 0 intent-router") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") - parser.add_argument("--user-text", default=None, help="用户原文") - parser.add_argument("--report-file", default=None, help="main 喂入的 viral-chaser 报告路径(可选)") + parser = argparse.ArgumentParser(description="Stage 1 intent-router") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + parser.add_argument("--user-text", default=None, help="甲方原文(Brief 主题句或用户描述)") + parser.add_argument("--report-file", default=None, help="甲方给的参考拆解报告路径(可选)") parser.add_argument("--genre", default=None, choices=sorted(VALID_GENRES), help="强制档位,跳过自动判定") parser.add_argument("--duration", type=int, default=None, help="时长目标(秒),不传走档位默认") parser.add_argument("--audience", default=None, help="受众描述") diff --git a/crews/content-producer/scripts/interp.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/interp.py similarity index 96% rename from crews/content-producer/scripts/interp.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/interp.py index d3dc1f0f..81cd671d 100644 --- a/crews/content-producer/scripts/interp.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/interp.py @@ -5,7 +5,7 @@ 15fps 用户素材)补到 30fps 顺滑——发布平台播放器默认 30fps 起,低于这 画面会卡。 -⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不动 fps。 +⚠️ 可选步骤,不是必跑。通用制作流程默认不动 fps。 **仅当源 fps < target fps** 且用户要"补帧"/"顺滑"/"提升帧率"时才跑。 minterpolate mode 怎么选: @@ -17,9 +17,9 @@ 干湿分离:输出 `_interp.mp4`,不覆盖输入。 Usage: - python3 ./scripts/interp.py - python3 ./scripts/interp.py --target-fps 30 --output - python3 ./scripts/interp.py --target-fps 60 --mode mci + video-producer interp + video-producer interp --target-fps 30 --output + video-producer interp --target-fps 60 --mode mci Exit codes: 0 ok,补帧完成(含源 fps ≥ target fps 自动跳过拷贝的 exit 0) diff --git a/crews/content-producer/skills/video-producer/scripts/make-cover.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py similarity index 52% rename from crews/content-producer/skills/video-producer/scripts/make-cover.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py index 67e0f98d..60b679c0 100644 --- a/crews/content-producer/skills/video-producer/scripts/make-cover.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-cover.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -"""Stage 14a — make-cover:封面(siliconflow-img-gen,必含标题文字)。 +"""Stage 14a — make-cover:封面(siliconflow-img-gen,必含封面主文案)。 Usage: python3 scripts/make-cover.py --title "..." -入:project_dir/script/brief.md(标题)+ storyboard 关键帧 -出:project_dir/cover.jpg(含标题文字的封面图) +入:project_dir/brief.md(封面主文案)+ storyboard 关键帧 +出:project_dir/cover.jpg(含封面主文案的封面图) -封面硬约束:必含标题文字。siliconflow-img-gen 不一定能把中文标题烤进图, -agent 生成后用 image 工具看,确认标题可见——不可见就用 ImageMagick/Pillow 烧字上去。 +封面硬约束:必含封面主文案。主文案由甲方在 Brief 中给出(有平台标题时用标题,视频号用短标题);Brief 未给时回退核心传达。siliconflow-img-gen 不一定能把中文封面主文案烤进图, +agent 生成后用 image 工具看,确认封面主文案可见——不可见就用 ImageMagick/Pillow 烧字上去。 """ import argparse @@ -24,8 +24,8 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 14a make-cover") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") - parser.add_argument("--title", default=None, help="封面标题文字,不传则从 brief.md 抽") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + parser.add_argument("--title", default=None, help="封面主文案,不传则从 brief.md 抽") args = parser.parse_args() project = Path(args.project_dir).resolve() @@ -39,7 +39,7 @@ def main() -> None: brief = project / "brief.md" if brief.is_file(): content = brief.read_text(encoding="utf-8") - # 抽第一个 # 标题或前 50 字作 title + # 抽第一个 # 标题作为封面主文案,或取前 50 字兜底 for line in content.splitlines(): if line.startswith("# "): title = line[2:].strip() @@ -47,19 +47,19 @@ def main() -> None: if not title: title = content[:50].strip() if not title: - die("需 --title 或 brief.md 含 # 标题") + die("需 --title 或 brief.md 含 # 封面主文案标题行") stub = { - "title": title, + "cover_copy": title, "instruction": ( - "agent 调公共 siliconflow-img-gen 生成封面图,prompt 必含标题文字指令。" - "生成后用 image 工具看,确认标题可见——不可见就用 ImageMagick/Pillow 烧字上去。" + "agent 调公共 siliconflow-img-gen 生成封面图,prompt 必含封面主文案指令。" + "生成后用 image 工具看,确认封面主文案可见——不可见就用 ImageMagick/Pillow 烧字上去。" "落 cover.jpg 到项目根。" ), "cover_path": str(cover), } - print(f"[plan] cover.jpg 标题:{title}") - print(f"[next] agent 调 siliconflow-img-gen 生成 → 确认标题可见 → 落 {cover}") + print(f"[plan] cover.jpg 封面主文案:{title}") + print(f"[next] agent 调 siliconflow-img-gen 生成 → 确认封面主文案可见 → 落 {cover}") if __name__ == "__main__": diff --git a/crews/content-producer/skills/video-producer/scripts/make-outro.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-outro.py similarity index 97% rename from crews/content-producer/skills/video-producer/scripts/make-outro.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-outro.py index af27922a..5545ae35 100644 --- a/crews/content-producer/skills/video-producer/scripts/make-outro.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/make-outro.py @@ -77,7 +77,7 @@ def hex_to_ffmpeg_color(hex_color: str) -> str: def main() -> None: parser = argparse.ArgumentParser(description="make-outro 片尾制作") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--image", required=True, help="形象图路径(PNG/JPG)") parser.add_argument("--slogan", required=True, help="slogan 文本(烧录到画面中央)") parser.add_argument("--color", default=None, help="颜色配置 JSON 路径") diff --git a/crews/content-producer/skills/video-producer/scripts/mix-audio.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py similarity index 97% rename from crews/content-producer/skills/video-producer/scripts/mix-audio.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py index ffcf20a0..df734fb7 100644 --- a/crews/content-producer/skills/video-producer/scripts/mix-audio.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/mix-audio.py @@ -40,7 +40,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 11 mix-audio") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/motion-audit.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-audit.py similarity index 96% rename from crews/content-producer/skills/video-producer/scripts/motion-audit.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-audit.py index beabb671..de669562 100644 --- a/crews/content-producer/skills/video-producer/scripts/motion-audit.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-audit.py @@ -32,7 +32,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 13b motion-audit") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-graphics.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-graphics.py new file mode 100644 index 00000000..95c0c00a --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/motion-graphics.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""motion-graphics — 程序化逐帧动态图形渲染(Stage 10 第二条渲染路径,与 render-shot 并列)。 + +声明式 JSON spec 驱动 PIL 逐帧绘制 + ffmpeg 低载编码,只产出**单个**动态图形片段 +clip.mp4(默认 1920x1080@25fps,帧级 checkpoint + 时长/帧率校验)。 +不做拼接、不做混音、不做字幕——那是 assemble / audio-mix / burn-srt 的活。 + +Usage: + python3 scripts/motion-graphics.py --spec slots/mg-s17.json + python3 scripts/motion-graphics.py --spec slots/mg-s17.json --out render/v4/s17/clip.mp4 + python3 scripts/motion-graphics.py --spec slots/mg-s19.json --force + +spec.json 结构(路径相对 spec 文件或绝对;颜色可写调色板名 GREEN/CYAN/GOLD/RED/INK/WHITE/MUTED 或 [r,g,b]): + { + "duration": 5.2, // 必填(或 CLI --duration 覆盖) + "fps": 25, "width": 1920, "height": 1080, + "font_dir": null, // 可选:Noto Sans SC 字体目录覆盖 + "background": {"type": "dark_grid"} // 暗色科技底(默认) + | {"type": "gradient", "top": [r,g,b], "bottom": [r,g,b], "grid": false} + | {"type": "image", "path": "...", "fit": "fill"|"contain", "dim": 0.42, "blur": 3} + | {"type": "video", "path": "...", "start": 0, "fit": "blur_pad"|"none"}, + // 二选一: + "template": "dimension_grid|scroll_cards|crew_panel|rec_highlight", "params": {...}, + "elements": [ {...}, ... ] // 基础元素按序叠画(z 序 = 数组序) + } + +基础元素公共字段(enter/exit/pulse 均可选): + "enter": {"at": 0.5, "dur": 0.55, "anim": "fade|rise|rise_back|scale_in", "ease": "out_cubic|out_back|in_out|linear", "dy": 130} + "exit": {"at": 4.0, "dur": 0.5, "anim": "fade|sink", "dy": 50} + "pulse": {"freq": 1.4, "amount": 0.25, "on": "color|outline"} + +元素类型: + text {"text","x","y","font":"black|bold|med|reg","size":60,"color","anchor":"mm","shadow":true} + card {"x","y","w","h","radius":28,"fill":[22,30,52],"fill_alpha":235,"outline","outline_width":3, + "bar":true,"title","title_font":"bold","title_size":56,"sub":[行],"sub_size":34} // x,y=左上角 + photo_circle {"path","x","y","size":160,"ring":"GREEN","ring_width":4} // x,y=圆心 + glow {"x","y","size":600,"color":"GREEN","alpha":60} // x,y=中心 + band {"image","x":0,"y":0} // 整幅叠加图(如绿横带 PNG),透明度走 enter + highlight_zone {"box":[x0,y0,x1,y1],"label","dim":110,"zone_color","corner_color","radius":26} + progress_bar {"x","y","w","h":12,"color":"GREEN","from":0.5,"to":null} // to 缺省 = duration-0.4 + custom {"plugin":"draw_x.py","params":{...}} // 逃生舱:plugin 暴露 draw(img, t, ctx), + // ctx 含 params/fonts/lib/w/h/fps/dur/state/spec_dir; + // 编码/checkpoint/时长校验仍在本子命令,plugin 只画帧 + +模板 params 详见 _mg_templates.py 各 prepare_*(四件套收编自 v4 已验证组件)。 +rec_highlight 模板必须配 background.type=video;其余模板/元素配非 video 底或 video 底均可 +(video 底自动走覆盖层合成:元素画 PNG 透明层,ffmpeg overlay 到底视频)。 + +checkpoint:/frames/ 帧已存在即跳过重画(断点续渲);clip.mp4 已存在整段跳过。 +改了 spec 必须 --force 全量重渲(帧目录一并清掉)。 +退出码:0 成功 / 1 spec 或参数错 / 2 环境缺失(字体、ffmpeg)。 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import _mg_lib as L +import _mg_templates as T +from PIL import Image, ImageDraw + +ELEMENT_TYPES = {"text", "card", "photo_circle", "glow", "band", + "highlight_zone", "progress_bar", "custom"} +# 每类元素的必填字段(load_spec 统一 die,避免渲到一半 KeyError 或静默不画) +REQUIRED_FIELDS = { + "text": ("text", "x", "y"), + "card": ("x", "y", "w", "h"), + "photo_circle": ("path", "x", "y"), + "glow": ("x", "y"), + "band": ("image",), + "highlight_zone": ("box",), + "progress_bar": ("x", "y", "w"), + "custom": ("plugin",), +} +FRAME_TEMPLATES = {name for name, (_, _, mode) in T.TEMPLATES.items() if mode == "frame"} + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def resolve_from(raw, base_dir: Path) -> Path: + p = Path(str(raw)) + return p if p.is_absolute() else base_dir / p + + +# ============================================================ spec 装载与校验 +def load_spec(spec_path: Path, cli_duration: float | None) -> dict: + if not spec_path.is_file(): + die(f"spec 不存在: {spec_path}") + try: + spec = json.loads(spec_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"spec 不是合法 JSON: {spec_path}({e})") + if cli_duration is not None: + spec["duration"] = cli_duration + if not spec.get("duration") or float(spec["duration"]) <= 0: + die("spec.duration 必填且 > 0(秒),或 CLI 传 --duration") + has_tpl = "template" in spec + has_els = "elements" in spec + if has_tpl == has_els: + die("spec 必须且只能给 template 或 elements 之一") + if has_tpl and spec["template"] not in T.TEMPLATES: + die(f"未知模板: {spec['template']}(可选 {'/'.join(T.TEMPLATES)})") + if has_els: + if not isinstance(spec["elements"], list) or not spec["elements"]: + die("spec.elements 必须是非空列表") + for i, el in enumerate(spec["elements"]): + if not isinstance(el, dict) or el.get("type") not in ELEMENT_TYPES: + die(f"elements[{i}] 未知类型: {el.get('type') if isinstance(el, dict) else el!r}" + f"(可选 {'/'.join(sorted(ELEMENT_TYPES))})") + missing = [f for f in REQUIRED_FIELDS[el["type"]] if f not in el] + if missing: + die(f"elements[{i}]({el['type']})缺必填字段: {'/'.join(missing)}") + bg = spec.get("background") or {"type": "dark_grid"} + if bg.get("type") not in ("dark_grid", "gradient", "image", "video"): + die(f"未知 background.type: {bg.get('type')}(可选 dark_grid/gradient/image/video)") + if has_tpl and spec["template"] == "rec_highlight" and bg.get("type") != "video": + die("rec_highlight 模板必须配 background.type=video(圈选高亮叠在录屏底上)") + if has_tpl and spec["template"] in FRAME_TEMPLATES and bg.get("type") == "video": + die(f"{spec['template']} 是满帧模板,不能配 video 底(要叠录屏用 rec_highlight 或 elements)") + if bg.get("type") in ("image", "video"): + if not bg.get("path"): + die(f"background.type={bg['type']} 必须给 path") + return spec + + +def load_plugin(el: dict, spec_dir: Path): + """custom 元素逃生舱:按路径加载 plugin 模块,校验 draw 可调用。""" + if not el.get("plugin"): + die("custom 元素必须给 plugin 路径") + p = resolve_from(el["plugin"], spec_dir) + if not p.is_file(): + die(f"custom plugin 不存在: {p}") + mod_spec = importlib.util.spec_from_file_location(f"mg_plugin_{p.stem}", p) + if mod_spec is None or mod_spec.loader is None: + die(f"custom plugin 必须是可加载的 .py 文件: {p}") + mod = importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + if not callable(getattr(mod, "draw", None)): + die(f"custom plugin 必须暴露 draw(img, t, ctx): {p}") + return mod + + +# ============================================================ 动画状态 +def pulse_mod(el: dict, t: float) -> float: + """pulse 振荡值 0..1(无 pulse 恒 0)。""" + pl = el.get("pulse") + return L.pulse(t, float(pl["freq"])) if pl else 0.0 + + +def pulse_amount(el: dict) -> float: + pl = el.get("pulse") or {} + return float(pl.get("amount", 0.25)) + + +# ============================================================ 元素绘制 +def draw_text(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + fonts = ctx["fonts"] + text = str(el.get("text", "")) + if not text or a <= 0: + return + font = fonts.get(el.get("font", "bold"), int(el.get("size", 60))) + color = L.parse_color(el.get("color", "WHITE")) + osc = pulse_mod(el, t) + if (el.get("pulse") or {}).get("on", "color") == "color" and osc: + m = L.lerp(1 - pulse_amount(el), 1.0, osc) + color = tuple(int(c * m) for c in color) + fill = L.with_alpha(color, 255 * a) + x, y = float(el["x"]), float(el["y"]) + dy + if abs(sc - 1.0) > 0.001: + # scale_in:画到临时层再缩放(v4 s16 已验证做法) + bbox = font.getbbox(text) + tw, th = bbox[2] - bbox[0] + 40, bbox[3] - bbox[1] + 40 + layer = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) + dl = ImageDraw.Draw(layer) + anchor = el.get("anchor", "mm") + dl.text((tw / 2, th / 2), text, font=font, fill=fill, anchor=anchor) + layer = layer.resize((max(1, int(tw * sc)), max(1, int(th * sc))), Image.LANCZOS) + L.safe_composite(img, layer, int(x - layer.width / 2), int(y - layer.height / 2)) + else: + d = ImageDraw.Draw(img) + if el.get("shadow", True): + shadow = (0, 0, 0, int(180 * a)) + d.text((x + 2, y + 2), text, font=font, fill=shadow, anchor=el.get("anchor", "mm")) + d.text((x, y), text, font=font, fill=fill, anchor=el.get("anchor", "mm")) + + +def draw_card(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + if a <= 0: + return + fonts = ctx["fonts"] + x, y = float(el["x"]), float(el["y"]) + dy + w, h = int(el["w"]), int(el["h"]) + radius = int(el.get("radius", 28)) + fill = L.with_alpha(L.parse_color(el.get("fill", [22, 30, 52])), + int(el.get("fill_alpha", 235)) * a) + outline = L.parse_color(el.get("outline", "CYAN")) + ow = int(el.get("outline_width", 3)) + osc = pulse_mod(el, t) + if (el.get("pulse") or {}).get("on") == "outline" and osc: + ow_a = int((160 + 90 * osc) * a) + else: + ow_a = int(255 * a) + if abs(sc - 1.0) > 0.001: + w, h = int(w * sc), int(h * sc) + card = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + d = ImageDraw.Draw(card) + d.rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=fill, + outline=L.with_alpha(outline, ow_a), width=ow) + if el.get("bar", True): + d.rounded_rectangle([28, 30, w - 28, 44], radius=7, + fill=L.with_alpha(outline, int(220 * a))) + title = el.get("title") + if title: + tsize = int(el.get("title_size", 56)) + ty = 150 if h >= 400 else h * 0.34 + d.text((w / 2, ty), str(title), font=fonts.get(el.get("title_font", "bold"), tsize), + fill=L.with_alpha(L.PALETTE["WHITE"], 255 * a), anchor="mm") + sub = el.get("sub") + if sub: + lines = sub if isinstance(sub, list) else [sub] + ssize = int(el.get("sub_size", 34)) + sy = (326 if h >= 400 else h * 0.58) + for ln in lines: + d.text((w / 2, sy), str(ln), font=fonts.get("med", ssize), + fill=L.with_alpha((168, 182, 205), 255 * a), anchor="mm") + sy += ssize * 1.53 + L.safe_composite(img, card, int(x), int(y)) + + +def draw_photo_circle(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + if a <= 0: + return + key = f"photo:{el['path']}:{el.get('size', 160)}" + if key not in ctx["state"]: + p = resolve_from(el["path"], ctx["spec_dir"]) + if not p.is_file(): + die(f"photo_circle 图片不存在: {p}") + ctx["state"][key] = L.photo_circle(p, int(el.get("size", 160))) + ph = ctx["state"][key] + if a < 1.0: + ph = ph.copy() + ph.putalpha(ph.getchannel("A").point(lambda v: int(v * a))) + size = ph.width + x, y = float(el["x"]), float(el["y"]) + dy + L.safe_composite(img, ph, int(x - size / 2), int(y - size / 2)) + ring = el.get("ring") + if ring: + d = ImageDraw.Draw(img) + d.ellipse([x - size / 2 - 6, y - size / 2 - 6, + x + size / 2 + 6, y + size / 2 + 6], + outline=L.with_alpha(L.parse_color(ring), 255 * a), + width=int(el.get("ring_width", 4))) + + +def draw_glow(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + if a <= 0: + return + alpha = float(el.get("alpha", 60)) * a + osc = pulse_mod(el, t) + if osc and (el.get("pulse") or {}).get("on", "glow") in ("glow", "alpha"): + alpha *= 1 + pulse_amount(el) * osc + L.paste_glow(img, (float(el["x"]), float(el["y"]) + dy), int(el.get("size", 600)), + L.parse_color(el.get("color", "GREEN")), int(alpha)) + + +def draw_band(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + if a <= 0: + return + key = f"band:{el['image']}" + if key not in ctx["state"]: + p = resolve_from(el["image"], ctx["spec_dir"]) + if not p.is_file(): + die(f"band 图片不存在: {p}") + band = Image.open(p).convert("RGBA") + if band.size != (ctx["w"], ctx["h"]): + band = band.resize((ctx["w"], ctx["h"]), Image.LANCZOS) + ctx["state"][key] = band + band = ctx["state"][key] + if a < 1.0: + band = band.copy() + band.putalpha(band.getchannel("A").point(lambda v: int(v * a))) + L.safe_composite(img, band, int(el.get("x", 0)), int(el.get("y", 0) + dy)) + + +def draw_highlight_zone(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + """静态单窗圈选(复用 rec_highlight 模板绘制逻辑,zones=[box];enter 只控出现/消失)。""" + if "box" not in el: + die("highlight_zone 缺 box 字段(load_spec 应已拦截)") + if a <= 0: + return + key = f"_hz:{id(el)}" + if key not in ctx["state"]: + params = { + "zones": [el["box"]], "label": el.get("label", ""), + "zone_color": el.get("zone_color", "CYAN"), + "corner_color": el.get("corner_color", "GREEN"), + "dim": el.get("dim", 110), "radius": el.get("radius", 26), + } + ctx["state"][key] = T.prepare_rec_highlight(params, ctx) + T.draw_rec_highlight(img, t, ctx["state"][key], ctx) + + +def draw_progress_bar(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + if a <= 0: + return + d = ImageDraw.Draw(img) + x, y, w = float(el["x"]), float(el["y"]) + dy, int(el["w"]) + h = int(el.get("h", 12)) + frm = float(el.get("from", 0.0)) + to = float(el.get("to", ctx["dur"] - 0.4)) + color = L.parse_color(el.get("color", "GREEN")) + track = L.parse_color(el.get("track", [40, 52, 74])) + d.rounded_rectangle([x, y, x + w, y + h], radius=h // 2, + fill=L.with_alpha(track, 255 * a)) + prog = L.seg(t, frm, to) + if prog > 0: + d.rounded_rectangle([x, y, x + int(w * prog), y + h], radius=h // 2, + fill=L.with_alpha(color, 255 * a)) + + +def draw_custom(img: Image.Image, el: dict, t: float, ctx: dict, a: float, dy: float, sc: float) -> None: + key = f"plugin:{el['plugin']}" + if key not in ctx["state"]: + ctx["state"][key] = load_plugin(el, ctx["spec_dir"]) + pctx = {**ctx, "params": el.get("params") or {}, "alpha": a} + ctx["state"][key].draw(img, t, pctx) + + +ELEMENT_DRAWERS = { + "text": draw_text, + "card": draw_card, + "photo_circle": draw_photo_circle, + "glow": draw_glow, + "band": draw_band, + "highlight_zone": draw_highlight_zone, + "progress_bar": draw_progress_bar, + "custom": draw_custom, +} + + +# ============================================================ 背景 +def build_background_frame(spec: dict, ctx: dict) -> Image.Image: + """非 video 底的每帧底图(RGBA)。""" + bg = spec.get("background") or {"type": "dark_grid"} + w, h = ctx["w"], ctx["h"] + btype = bg["type"] + if btype == "dark_grid": + return L.dark_bg(w, h, grid=bool(bg.get("grid", True))) + if btype == "gradient": + top = L.parse_color(bg.get("top", [9, 15, 28])) + bottom = L.parse_color(bg.get("bottom", [16, 27, 48])) + img = L.vgrad(w, h, top, bottom).convert("RGBA") + if bg.get("grid"): + d = ImageDraw.Draw(img) + step = max(48, w // 20) + for x in range(0, w, step): + d.line([(x, 0), (x, h)], fill=(255, 255, 255, 7)) + for y in range(0, h, step): + d.line([(0, y), (w, y)], fill=(255, 255, 255, 7)) + return img + if btype == "image": + key = "_bg_image" + if key not in ctx["state"]: + p = resolve_from(bg["path"], ctx["spec_dir"]) + if not p.is_file(): + die(f"background 图片不存在: {p}") + ctx["state"][key] = L.load_base_image( + p, w, h, fit=bg.get("fit", "fill"), + dim=float(bg.get("dim", 0)), blur=float(bg.get("blur", 0))) + return ctx["state"][key].copy().convert("RGBA") + raise L.MgError(f"build_background_frame 不该收到 background.type={btype}") + + +def prepare_base_video(spec: dict, ctx: dict, work_dir: Path, enc: dict) -> Path: + """video 底预处理:裁到 (start, duration),fit=blur_pad 时归一到目标画幅。产物即 checkpoint。""" + bg = spec["background"] + src = resolve_from(bg["path"], ctx["spec_dir"]) + if not src.is_file(): + die(f"background 视频不存在: {src}") + prepped = work_dir / "base_prepped.mp4" + if prepped.is_file() and prepped.stat().st_size > 0: + print(f"[checkpoint] 底视频已处理:{prepped}") + return prepped + start = float(bg.get("start", 0)) + fit = bg.get("fit", "blur_pad") + if fit == "blur_pad": + L.fit_video_blur_pad(src, prepped, ctx["w"], ctx["h"], ctx["fps"], + enc["crf"], enc["preset"], enc["threads"], enc["nice"], + start=start, duration=ctx["dur"]) + elif fit == "none": + cmd = (["nice", "-n", enc["nice"]] if enc.get("nice") else []) + \ + ["ffmpeg", "-y", "-v", "error", "-ss", str(start), "-i", str(src), + "-t", f"{ctx['dur']:.3f}", "-an", + "-c:v", "libx264", "-crf", enc["crf"], "-preset", enc["preset"], + "-threads", enc["threads"], str(prepped)] + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + die(f"底视频裁剪失败: {p.stderr[:500]}") + else: + die(f"未知 background.fit: {fit}(video 底可选 blur_pad/none)") + print(f"[prep] 底视频就绪:{prepped}") + return prepped + + +# ============================================================ 主流程 +def render(spec: dict, ctx: dict, out: Path, enc: dict, force: bool) -> None: + work_dir = out.parent + fdir = work_dir / "frames" + fdir.mkdir(parents=True, exist_ok=True) + if force: + shutil.rmtree(fdir, ignore_errors=True) + fdir.mkdir(parents=True, exist_ok=True) + work_dir.joinpath("base_prepped.mp4").unlink(missing_ok=True) + + w, h, fps, dur = ctx["w"], ctx["h"], ctx["fps"], ctx["dur"] + n = L.nframes(dur, fps) + bg_type = (spec.get("background") or {"type": "dark_grid"})["type"] + overlay_mode = bg_type == "video" + + base_video = None + if overlay_mode: + base_video = prepare_base_video(spec, ctx, work_dir, enc) + + # 模板 prepare(校验 + 补默认值;frame 模板配 video 底已在 load_spec 拒绝) + tpl_draw = None + tpl_params = None + if "template" in spec: + prepare, tpl_draw, mode = T.TEMPLATES[spec["template"]] + if mode == "overlay" and not overlay_mode: + die(f"模板 {spec['template']} 需要 video 底") + tpl_params = prepare(spec.get("params") or {}, ctx) + + skipped = 0 + for i in range(n): + if L.frame_exists(fdir, i): + skipped += 1 + continue + t = i / fps + if overlay_mode: + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + else: + img = build_background_frame(spec, ctx) + if tpl_draw is not None: + tpl_draw(img, t, tpl_params, ctx) + for el in spec.get("elements") or []: + a, dy, sc = element_anim(el, t) + if a <= 0.004: + continue + ELEMENT_DRAWERS[el["type"]](img, el, t, ctx, a, dy, sc) + if overlay_mode: + L.save_frame_png(img, fdir, i) + else: + L.save_frame_jpg(img, fdir, i) + if skipped: + print(f"[checkpoint] 复用已渲帧 {skipped}/{n}") + + print(f"[encode] {n} 帧 → {out}({w}x{h}@{fps},crf {enc['crf']}/{enc['preset']}/threads {enc['threads']})") + L.encode_frames(fdir, out, fps, enc["crf"], enc["preset"], enc["threads"], + enc["nice"], base_video=base_video) + + # 规格断言:时长 ± 容差、帧率一致、分辨率一致 + # (overlay 模式输出尺寸跟底视频走:fit=none 且底视频非目标画幅时圈选坐标会全错位) + actual = L.probe_duration(out) + if abs(actual - dur) > L.DUR_TOLERANCE: + die(f"成片时长校验失败:实测 {actual:.3f}s vs 计划 {dur:.3f}s(容差 ±{L.DUR_TOLERANCE}s)") + size = L.probe_size(out) + if size != (w, h): + die(f"成片分辨率校验失败:实测 {size[0]}x{size[1]} vs spec {w}x{h}" + f"(video 底 fit=none 时底视频须已是目标画幅,否则用 blur_pad)") + if float(fps).is_integer(): + afr = L.probe_frame_rate(out) + want = f"{int(fps)}/1" + if afr != want: + die(f"成片帧率校验失败:avg_frame_rate={afr},期望 {want}") + print(f"[done] {out}({actual:.3f}s {w}x{h}@{fps},校验通过)") + + +def element_anim(el: dict, t: float) -> tuple[float, float, float]: + """(alpha, dy, scale)——enter/exit 求值,pulse 由各 drawer 自取。""" + alpha, dy, scale = 1.0, 0.0, 1.0 + enter = el.get("enter") or {} + ex = el.get("exit") or {} + at, adur = float(enter.get("at", 0.0)), float(enter.get("dur", 0.0)) + anim = enter.get("anim", "none") + if anim != "none": + ease = L.get_ease(enter.get("ease", "out_back" if anim == "rise_back" else "out_cubic")) + p = ease(L.seg(t, at, at + adur)) if adur > 0 else (1.0 if t >= at else 0.0) + alpha *= p + if anim in ("rise", "rise_back"): + dy += (1 - p) * float(enter.get("dy", 130)) + elif anim == "scale_in": + scale = L.lerp(float(enter.get("scale_from", 0.85)), 1.0, p) + elif "at" in enter: + alpha *= 1.0 if t >= at else 0.0 + if ex: + xat, xdur = float(ex.get("at", 0.0)), float(ex.get("dur", 0.5)) + p2 = L.get_ease(ex.get("ease", "out_cubic"))(L.seg(t, xat, xat + xdur)) + alpha *= (1 - p2) + if ex.get("anim") == "sink": + dy += p2 * float(ex.get("dy", 50)) + return L.clamp(alpha, 0.0, 1.0), dy, scale + + +def main() -> None: + parser = argparse.ArgumentParser(description="motion-graphics 程序化逐帧动态图形渲染") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + parser.add_argument("--spec", required=True, help="声明式 spec JSON(相对 project_dir 或绝对路径)") + parser.add_argument("--out", default=None, + help="输出 clip 路径(默认 render/mg//clip.mp4,相对 project_dir 或绝对)") + parser.add_argument("--duration", type=float, default=None, help="覆盖 spec.duration(秒)") + parser.add_argument("--force", action="store_true", help="清帧全量重渲(改 spec 后必须)") + parser.add_argument("--crf", default="18", help="x264 crf(默认 18,低载验证值)") + parser.add_argument("--preset", default="veryfast", help="x264 preset(默认 veryfast)") + parser.add_argument("--threads", default="2", help="编码线程(默认 2,共享机器低载)") + parser.add_argument("--nice", default="19", help="nice 优先级(默认 19;传 0 关闭)") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + if not project.is_dir(): + die(f"项目目录不存在: {project}") + for binary in ("ffmpeg", "ffprobe"): + if shutil.which(binary) is None: + die(f"缺 {binary}(motion-graphics 依赖 ffmpeg 全套)", code=2) + spec_path = resolve_from(args.spec, project).resolve() + spec_dir = spec_path.parent + + try: + spec = load_spec(spec_path, args.duration) + w = int(spec.get("width", L.DEFAULT_W)) + h = int(spec.get("height", L.DEFAULT_H)) + fps = float(spec.get("fps", L.DEFAULT_FPS)) + dur = float(spec["duration"]) + fonts = L.FontBank(spec.get("font_dir")) + ctx = {"w": w, "h": h, "fps": fps, "dur": dur, + "fonts": fonts, "lib": L, "state": {}, "spec_dir": spec_dir} + + if args.out: + out = resolve_from(args.out, project) + else: + out = project / "render" / "mg" / spec_path.stem / "clip.mp4" + out = out.resolve() + if out.suffix.lower() != ".mp4": + die(f"--out 必须是 .mp4: {out}") + + if out.is_file() and out.stat().st_size > 0 and not args.force: + print(f"[checkpoint] clip 已存在:{out}(改了 spec 要 --force 重渲)") + return + + enc = {"crf": args.crf, "preset": args.preset, "threads": args.threads, + "nice": args.nice if args.nice not in ("0", "None", "") else None} + out.parent.mkdir(parents=True, exist_ok=True) + render(spec, ctx, out, enc, args.force) + except L.MgError as e: + die(str(e), code=e.code) + + print(f"[next] 片段作为段进拼接清单:assemble --manifest(显式段清单);" + f"响度/字幕/混音归各自子命令,不在本工具内联") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-producer/scripts/narration-align.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py similarity index 98% rename from crews/content-producer/skills/video-producer/scripts/narration-align.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py index 51362d02..bbaa6437 100644 --- a/crews/content-producer/skills/video-producer/scripts/narration-align.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py @@ -154,7 +154,7 @@ def main() -> None: load_env_file() parser = argparse.ArgumentParser(description="Stage 11b narration-align") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument( "--audio", default=None, diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-layout.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-layout.py new file mode 100644 index 00000000..fd272e2c --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-layout.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""narration-layout — 逐句旁白排布:对齐镜头起点 + 防重叠守卫 + 越界断言 + SRT + 可选混音。 + +原子工具,不写死 Workflow。适用于「逐句 TTS 出独立 mp3」的生产模式 +(reversal-ad / 解说类项目常用);整段旁白一条 narration.mp3 的时间戳对齐 +仍归 narration-align,两者互补、不互替。 + +Usage: + python3 scripts/narration-layout.py --plan audio/narration_plan.json \ + --srt audio/subtitles.srt --mix audio/mix.wav + +plan.json 结构(路径均相对 project_dir,也接受绝对路径): + { + "shots": [ # 有序镜头清单(实测 clip 时长累积出镜头起点) + {"id": "s01", "clip": "render/v4/s01/clip.mp4"}, + ... + ], + "narrations": [ # 逐句清单,按播出顺序 + {"file": "audio/narr/s01.mp3", "text": "注意看,……", + "shot_id": "s01", "lead_in": 0.25}, # 对齐镜头起点 + lead_in(默认 0.25s) + {"file": "audio/narr/x.mp3", "text": "……", "abs_start": 12.5} + # 或显式绝对起点(不参与守卫推移,冲突即报错) + ], + "bgm": {"file": "...", "volume": 0.22, "fade_in": 0.8, "fade_out": 2.2}, # 可选 + "guards": {"min_gap": 0.15, "tail_margin": 0.1, + "shot_tolerance": 0.0, "shot_overflow": "error"}, # 可选 + "srt_style": {"font_name": "Noto Sans SC", "font_size": 17, + "margin_v": 48, "outline": 1.2, "shadow": 0.5, "spacing": 0.5} # 可选 + } + +守卫语义(违反即非零退出并打印全部越界明细,不静默放行): + 1. 防重叠:shot_id 模式下句起点 = max(镜头起点+lead_in, 前句结束+min_gap),自动后推; + abs_start 模式下与前句间隔不足 min_gap 直接报错(显式时间不做主)。 + 2. 逐句越界:句尾不得越过对应镜头尾 + shot_tolerance;确需跨镜(桥句)时该句加 + "allow_spill": true,或 guards.shot_overflow 改 "warn"/"off"。 + 3. 末句越界:末句结束不得晚于成片总长 - tail_margin。 + +拼接约束:本工具产物按 hard 直拼(assemble 默认转场)的时间轴排布;fade/dissolve/xfade +类转场每处吃 0.5s 重叠,画面时间轴整体前移而排布/SRT/混音不感知——用了本工具就 +`assemble` 不传 --transition(或显式 hard)。 + +出: + audio/abs_starts.json 排布结果(镜头起点表 + 每句绝对起止 + force_style),排布即落盘 + --srt 指定路径 SRT(cue 尾提前 0.03s 防闪切),样式参数化进 abs_starts.json + --mix 指定路径 多轨混音(内部复用 audio-mix 子命令:N 路旁白 adelay + BGM fade) + +checkpoint:abs_starts.json / srt / mix 已存在则各自跳过,改了 plan.json 必须 --force 重排。 +退出码:0 成功 / 1 参数错或守卫断言失败。 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +DEFAULT_LEAD_IN = 0.25 +DEFAULT_MIN_GAP = 0.15 +DEFAULT_TAIL_MARGIN = 0.1 +DEFAULT_SHOT_TOLERANCE = 0.0 +SRT_END_TRIM = 0.03 # cue 尾提前量,防字幕闪切(v4 已验证) +DEFAULT_SRT_STYLE = { + "font_name": "Noto Sans SC", + "font_size": 17, + "primary_colour": "&H00FFFFFF", + "outline_colour": "&HA0000000", + "border_style": 1, + "outline": 1.2, + "shadow": 0.5, + "margin_v": 48, + "spacing": 0.5, +} + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd: list[str]) -> subprocess.CompletedProcess: + print(f"[cmd] {cmd[0]} ... ({len(cmd)} args)") + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + die(f"命令失败 (rc={p.returncode}): {p.stderr[:500] or p.stdout[:500]}") + return p + + +def probe_duration(path: Path) -> float: + p = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)], + capture_output=True, text=True, + ) + if p.returncode != 0: + return 0.0 + try: + return float(json.loads(p.stdout).get("format", {}).get("duration", 0) or 0) + except (ValueError, json.JSONDecodeError): + return 0.0 + + +def resolve(project: Path, raw: str) -> Path: + """plan 里的路径:绝对路径原样,相对路径挂到 project_dir。""" + p = Path(raw) + return p if p.is_absolute() else project / p + + +def srt_ts(seconds: float) -> str: + """秒 → SRT 时间戳 HH:MM:SS,mmm。""" + if seconds < 0: + seconds = 0.0 + h = int(seconds // 3600) + m = int(seconds % 3600 // 60) + s = int(seconds % 60) + ms = int(round((seconds - int(seconds)) * 1000)) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def build_force_style(style: dict) -> str: + """srt_style dict → libass force_style 串(供 burn-srt --force-style 直接引用)。""" + merged = {**DEFAULT_SRT_STYLE, **style} + return ( + f"FontName={merged['font_name']},FontSize={merged['font_size']}," + f"PrimaryColour={merged['primary_colour']},OutlineColour={merged['outline_colour']}," + f"BorderStyle={merged['border_style']},Outline={merged['outline']}," + f"Shadow={merged['shadow']},MarginV={merged['margin_v']},Spacing={merged['spacing']}" + ) + + +def load_plan(project: Path, plan_path: Path) -> dict: + if not plan_path.is_file(): + die(f"plan 不存在: {plan_path}") + try: + plan = json.loads(plan_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"plan 不是合法 JSON: {plan_path}({e})") + if not isinstance(plan.get("shots"), list) or not plan["shots"]: + die("plan.shots 必须是非空有序镜头清单 [{id, clip}, ...]") + if not isinstance(plan.get("narrations"), list) or not plan["narrations"]: + die("plan.narrations 必须是非空逐句清单 [{file, text, shot_id|abs_start}, ...]") + return plan + + +def probe_shots(project: Path, raw_shots: list) -> tuple[list[dict], float]: + """实测各镜 clip 时长,累积镜头起点。返回 (shots, total)。""" + shots: list[dict] = [] + t = 0.0 + for entry in raw_shots: + if not isinstance(entry, dict) or "id" not in entry or "clip" not in entry: + die(f"plan.shots 条目须为 {{id, clip}}: {entry!r}") + clip = resolve(project, entry["clip"]) + if not clip.is_file(): + die(f"镜头 clip 不存在: {clip}(shot {entry['id']})") + dur = probe_duration(clip) + if dur <= 0: + die(f"镜头 clip 时长探测失败: {clip}(shot {entry['id']})") + shots.append({"id": str(entry["id"]), "clip": str(clip), + "start": round(t, 3), "end": round(t + dur, 3), "dur": round(dur, 3)}) + t += dur + return shots, round(t, 3) + + +def layout(project: Path, plan: dict, shots: list[dict], total: float) -> tuple[list[dict], list[str], list[str]]: + """逐句排布 + 守卫。返回 (排布结果, errors, warnings)。""" + guards = plan.get("guards") or {} + min_gap = float(guards.get("min_gap", DEFAULT_MIN_GAP)) + tail_margin = float(guards.get("tail_margin", DEFAULT_TAIL_MARGIN)) + shot_tolerance = float(guards.get("shot_tolerance", DEFAULT_SHOT_TOLERANCE)) + shot_overflow = guards.get("shot_overflow", "error") + if shot_overflow not in ("error", "warn", "off"): + die(f"guards.shot_overflow 只能是 error/warn/off,收到 {shot_overflow!r}") + shot_by_id = {s["id"]: s for s in shots} + + errors: list[str] = [] + warnings: list[str] = [] + entries: list[dict] = [] + prev_end: float | None = None # 首句没有前句,min_gap 守卫不适用(None 哨兵) + for idx, item in enumerate(plan["narrations"], 1): + if not isinstance(item, dict) or "file" not in item: + die(f"plan.narrations[{idx}] 缺 file 字段: {item!r}") + narr = resolve(project, item["file"]) + if not narr.is_file(): + die(f"旁白文件不存在: {narr}(第 {idx} 句)") + dur = probe_duration(narr) + if dur <= 0: + die(f"旁白时长探测失败: {narr}(第 {idx} 句)") + + has_shot = "shot_id" in item + has_abs = "abs_start" in item + if has_shot == has_abs: + die(f"第 {idx} 句必须且只能给 shot_id 或 abs_start 之一: {item!r}") + + if has_shot: + shot = shot_by_id.get(str(item["shot_id"])) + if shot is None: + die(f"第 {idx} 句 shot_id 不在 shots 清单: {item['shot_id']}") + lead_in = float(item.get("lead_in", DEFAULT_LEAD_IN)) + if lead_in < 0: + die(f"第 {idx} 句 lead_in 必须 >= 0: {lead_in}") + start = shot["start"] + lead_in + if prev_end is not None: + floor = prev_end + min_gap + if start < floor: + start = floor # 防重叠守卫:自动后推 + else: + shot = None + start = float(item["abs_start"]) + if start < 0: + die(f"第 {idx} 句 abs_start 必须 >= 0: {start}") + if prev_end is not None and start < prev_end + min_gap: + errors.append( + f"第 {idx} 句 abs_start={start:.3f} 与前句结束 {prev_end:.3f} " + f"间隔不足 min_gap={min_gap}(abs_start 模式不自动推移)" + ) + + end = start + dur + if shot is not None and not item.get("allow_spill", False) and shot_overflow != "off": + over = end - (shot["end"] + shot_tolerance) + if over > 1e-6: + msg = (f"第 {idx} 句越过镜头 {shot['id']} 尾 {over:.3f}s" + f"(句尾 {end:.3f} > 镜尾 {shot['end']:.3f} + 容差 {shot_tolerance})" + f"——改镜头时长 / 加 allow_spill / 调 guards.shot_overflow") + (warnings if shot_overflow == "warn" else errors).append(msg) + + entries.append({ + "idx": idx, "file": str(narr), "text": item.get("text", ""), + "shot_id": shot["id"] if shot else None, + "start": round(start, 3), "end": round(end, 3), "dur": round(dur, 3), + }) + prev_end = end + + last = entries[-1] + if last["end"] > total - tail_margin + 1e-6: + errors.append( + f"末句结束 {last['end']:.3f} 越过成片尾(总长 {total:.3f} - tail_margin {tail_margin})" + ) + return entries, errors, warnings + + +def write_srt(path: Path, entries: list[dict]) -> None: + blocks: list[str] = [] + for e in entries: + text = e["text"] + if not text: + die(f"--srt 需要每句 text 字段,第 {e['idx']} 句缺失") + cue_end = max(e["start"] + 0.05, e["end"] - SRT_END_TRIM) + blocks.append(f"{e['idx']}\n{srt_ts(e['start'])} --> {srt_ts(cue_end)}\n{text}\n") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(blocks), encoding="utf-8") + + +def mix_audio(project: Path, entries: list[dict], bgm: dict | None, + total: float, out: Path) -> None: + """复用 audio-mix 子命令:N 路旁白 adelay + BGM volume/fade,--duration 卡总长。""" + script = Path(__file__).resolve().with_name("audio-mix.py") + cmd = [sys.executable, str(script)] + for e in entries: + cmd += ["--track", e["file"], "--delay", f"{e['start']:.3f}", + "--volume", "1.0", "--fadein", "0", "--fadeout", "0"] + if bgm: + bgm_file = resolve(project, bgm["file"]) + if not bgm_file.is_file(): + die(f"bgm 文件不存在: {bgm_file}") + cmd += ["--track", str(bgm_file), "--delay", "0", + "--volume", str(float(bgm.get("volume", 0.2))), + "--fadein", str(float(bgm.get("fade_in", 0))), + "--fadeout", str(float(bgm.get("fade_out", 0)))] + cmd += ["--output", str(out), "--duration", f"{total:.3f}"] + run(cmd) + + +def main() -> None: + parser = argparse.ArgumentParser(description="narration-layout 逐句旁白排布 + 守卫 + SRT + 可选混音") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + parser.add_argument("--plan", required=True, + help="排布计划 JSON(相对 project_dir 或绝对路径),结构见脚本头注释") + parser.add_argument("--srt", default=None, help="SRT 输出路径(相对 project_dir 或绝对;不传则不产 SRT)") + parser.add_argument("--mix", default=None, help="混音输出路径(相对 project_dir 或绝对;不传则不混音)") + parser.add_argument("--force", action="store_true", help="忽略 checkpoint 强制重排/重产") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + if not project.is_dir(): + die(f"项目目录不存在: {project}") + plan = load_plan(project, resolve(project, args.plan)) + + if args.srt: + for item in plan["narrations"]: + if not (isinstance(item, dict) and item.get("text")): + die(f"--srt 需要 plan.narrations 每句带 text 字段: {item!r}") + + audio_dir = project / "audio" + audio_dir.mkdir(parents=True, exist_ok=True) + abs_path = audio_dir / "abs_starts.json" + + if abs_path.is_file() and not args.force: + print(f"[checkpoint] abs_starts.json 已存在:{abs_path}(改了 plan 要 --force 重排)") + result = json.loads(abs_path.read_text(encoding="utf-8")) + entries, total = result["narrations"], result["total"] + else: + shots, total = probe_shots(project, plan["shots"]) + entries, errors, warnings = layout(project, plan, shots, total) + for w in warnings: + print(f"[warn] {w}") + if errors: + print(f"[fail] 守卫断言失败 {len(errors)} 处,未落盘任何产物:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + sys.exit(1) + force_style = build_force_style(plan.get("srt_style") or {}) + result = { + "total": total, + "shots": shots, + "narrations": entries, + "bgm": plan.get("bgm"), + "guards": plan.get("guards") or {}, + "force_style": force_style, + } + tmp = abs_path.with_suffix(".tmp") + tmp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(abs_path) + print(f"[done] 排布落盘:{abs_path}") + print(f" - {len(shots)} 镜 / {len(entries)} 句 / 成片总长 {total:.3f}s") + for e in entries: + print(f" [{e['idx']:>2}] {e['start']:>8.3f} → {e['end']:>8.3f} " + f"shot={e['shot_id'] or '-':<6} {Path(e['file']).name}") + + if args.srt: + srt_out = resolve(project, args.srt) + if srt_out.is_file() and not args.force: + print(f"[checkpoint] SRT 已存在:{srt_out}") + else: + write_srt(srt_out, entries) + print(f"[done] SRT 已落:{srt_out}") + print(f" - burn-srt 引用样式:--force-style '{result['force_style']}'") + + if args.mix: + mix_out = resolve(project, args.mix) + if mix_out.is_file() and not args.force: + print(f"[checkpoint] 混音已存在:{mix_out}") + else: + mix_out.parent.mkdir(parents=True, exist_ok=True) + mix_audio(project, entries, plan.get("bgm"), total, mix_out) + print(f"[done] 混音已落:{mix_out}") + + print(f"[next] 拼接走 assemble --manifest(守卫已验帧率/时长后再合成);" + f"响度归一成片后仍必须走 normalize,不在本工具内联 loudnorm") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/normalize.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/normalize.py similarity index 94% rename from crews/content-producer/scripts/normalize.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/normalize.py index 698f7e1e..06365ba7 100644 --- a/crews/content-producer/scripts/normalize.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/normalize.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Loudness normalization — 发布平台通用响度归一化。 -把成片音频响度归一化到 -14 LUFS(短视频平台通用标准:抖音/视频号/B 竍竖屏通用)。 +把成片音频响度归一化到 -14 LUFS(短视频平台通用标准:抖音/视频号/B 站竖屏通用)。 跑在合成后、自检/交付前。这条是必跑步骤——但脚本 -本身尊重 --skip 时跳过,由 caller(AGENTS.md 工作流)决定是否强制。 +本身尊重 --skip 时跳过;是否强制由 caller 决定(通用制作流程 Stage 13c 规定必跑)。 为什么 -14 LUFS: -- 抖音/视频号/B 竍竖屏发布通用标准,与平台播放器电平匹配,避免"在我机 sound bar +- 抖音/视频号/B 站竖屏发布通用标准,与平台播放器电平匹配,避免"在我机 sound bar 听着正"但"在手机刷到时偏轻/偏响" - industry de-facto for short-form video @@ -18,9 +18,9 @@ caller 决定是 rename 替换还是双轨保留。 Usage: - python3 ./scripts/normalize.py - python3 ./scripts/normalize.py --output - python3 ./scripts/normalize.py + video-producer normalize --output + video-producer normalize --report-file path + +入:project_dir(CP 自建工作区 output_videos//)+ 参考拆解报告路径 +出:project_dir/reference/concepts.md(2–3 差异化概念 + 成本 + 备选路径) + +无报告则跳过(本脚本不报错退出),agent 直接按通用制作流程推进。 +""" + +import argparse +import sys +from pathlib import Path + +def main() -> None: + parser = argparse.ArgumentParser(description="reference-concepts(可选:据参考拆解报告出差异化概念)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") + parser.add_argument("--report-file", default=None, help="甲方给的参考拆解报告路径(如 main 的 viral-chaser 产物)") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + out_dir = project / "reference" + out_dir.mkdir(parents=True, exist_ok=True) + concepts_path = out_dir / "concepts.md" + + # checkpoint + if concepts_path.is_file(): + print(f"[checkpoint] concepts.md 已存在,沿用:{concepts_path}") + return + + if not args.report_file: + print("[skip] 无参考拆解报告输入,跳过本工具") + return + + report = Path(args.report_file) + if not report.is_file(): + print(f"[warn] 报告文件不存在: {args.report_file},跳过本工具") + return + + # 复制报告原档到工作区(不做任何下载/转写/抽帧,只存档供后续阶段参考) + import shutil + archived = out_dir / "reference-report.md" + if not archived.is_file(): + shutil.copy2(report, archived) + + # 提示 agent 据报告出 2–3 差异化概念写入 concepts.md + stub = f"""# 据参考片出的差异化概念 + +## 参考片来源 + +{archived.name}(甲方给的参考拆解报告原档,本工具未做下载/转写/抽帧)。 + +## 概念候选(agent 据报告填) + +> agent 读 archived 报告,出 2–3 个**差异化**概念(不抄原片,做差异化),每个概念含: +> - 名称与一句话定位 +> - 与参考片的差异化点(节奏/钩子/结构/调性任一的差异化) +> - 预算估算(USD) +> - 备选路径(如该概念走不通的 fallback) + +### 概念 1 +(agent 填) + +### 概念 2 +(agent 填) + +### 概念 3(可选) +(agent 填) + +## 用户选定 + +> 呈交甲方(Brief owner)选定一个概念,写入 brief.md。未选定前不进 Stage 2。 +""" + concepts_path.write_text(stub, encoding="utf-8") + print(f"[done] 报告已存档:{archived}") + print(f"[stub] concepts.md 模板已落:{concepts_path}") + print(f"[next] agent 据报告填概念 → 呈交甲方选定 → 回通用制作流程(story-develop)") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-producer/scripts/render-shot.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py similarity index 96% rename from crews/content-producer/skills/video-producer/scripts/render-shot.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py index 10e9674d..1a11c31b 100644 --- a/crews/content-producer/skills/video-producer/scripts/render-shot.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/render-shot.py @@ -30,7 +30,7 @@ def die(msg: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Stage 10 render-shot") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--shot-id", default=None, help="只渲某镜,不传则提示 agent 逐镜跑") parser.add_argument("--dry-run", action="store_true", help="只打印调用计划不真渲") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/scene-compose.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/scene-compose.py similarity index 98% rename from crews/content-producer/skills/video-producer/scripts/scene-compose.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/scene-compose.py index efab8f26..b2ba34f5 100644 --- a/crews/content-producer/skills/video-producer/scripts/scene-compose.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/scene-compose.py @@ -70,7 +70,7 @@ def run(cmd: list[str]) -> subprocess.CompletedProcess: def main() -> None: parser = argparse.ArgumentParser(description="scene-compose 单 Scene 分段合成") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--scene", required=True, help="Scene JSON 路径(相对 project_dir 或绝对)") parser.add_argument("--output", default=None, help="输出 Scene 片段名(相对 project_dir;默认从 scene JSON 推导)") diff --git a/crews/content-producer/skills/video-producer/scripts/script-self-eval.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py similarity index 94% rename from crews/content-producer/skills/video-producer/scripts/script-self-eval.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py index 930aa5d3..44e7b86f 100644 --- a/crews/content-producer/skills/video-producer/scripts/script-self-eval.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-self-eval.py @@ -35,7 +35,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 3b script-self-eval") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/script-write.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py similarity index 95% rename from crews/content-producer/skills/video-producer/scripts/script-write.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py index e4332b23..b08555c5 100644 --- a/crews/content-producer/skills/video-producer/scripts/script-write.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/script-write.py @@ -23,7 +23,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 3 script-write") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/shot-decompose.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py similarity index 95% rename from crews/content-producer/skills/video-producer/scripts/shot-decompose.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py index cc688eab..e1641499 100644 --- a/crews/content-producer/skills/video-producer/scripts/shot-decompose.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/shot-decompose.py @@ -25,7 +25,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 5 shot-decompose") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py similarity index 95% rename from crews/content-producer/skills/video-producer/scripts/slideshow-risk.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py index ecdd4781..de13cf15 100644 --- a/crews/content-producer/skills/video-producer/scripts/slideshow-risk.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slideshow-risk.py @@ -36,7 +36,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 9a slideshow-risk") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/slot-plan.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py similarity index 96% rename from crews/content-producer/skills/video-producer/scripts/slot-plan.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py index e573ab20..4d132185 100644 --- a/crews/content-producer/skills/video-producer/scripts/slot-plan.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/slot-plan.py @@ -35,7 +35,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 7 slot-plan") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--tone", default=None, choices=sorted(TONE_SLOT_TABLE), help="调性,不传走 narrative 默认") args = parser.parse_args() diff --git a/crews/content-producer/skills/video-producer/scripts/story-develop.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py similarity index 94% rename from crews/content-producer/skills/video-producer/scripts/story-develop.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py index 0872e806..582ec67d 100644 --- a/crews/content-producer/skills/video-producer/scripts/story-develop.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/story-develop.py @@ -18,7 +18,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 2 story-develop") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/storyboard-build.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py similarity index 94% rename from crews/content-producer/skills/video-producer/scripts/storyboard-build.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py index 4763fe3b..2186f167 100644 --- a/crews/content-producer/skills/video-producer/scripts/storyboard-build.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/storyboard-build.py @@ -24,7 +24,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="Stage 4 storyboard-build") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") args = parser.parse_args() project = Path(args.project_dir).resolve() diff --git a/crews/content-producer/skills/video-producer/scripts/timeline-compose.py b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/timeline-compose.py similarity index 98% rename from crews/content-producer/skills/video-producer/scripts/timeline-compose.py rename to crews/content-producer/skills/expert-video/tools/video-producer/scripts/timeline-compose.py index 1af6a4f6..c9fbb1bc 100644 --- a/crews/content-producer/skills/video-producer/scripts/timeline-compose.py +++ b/crews/content-producer/skills/expert-video/tools/video-producer/scripts/timeline-compose.py @@ -74,7 +74,7 @@ def probe_duration(path: Path) -> float: def main() -> None: parser = argparse.ArgumentParser(description="timeline-compose 时间轴合成") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") + parser.add_argument("project_dir", help="项目目录(CP 自建工作区 output_videos//)") parser.add_argument("--timeline", required=True, help="时间轴 JSON 路径(相对 project_dir 或绝对)") parser.add_argument("--output", default="video.mp4", help="输出合成片段名(相对 project_dir,默认 video.mp4)") parser.add_argument("--transition", default="hard", choices=["hard", "fade", "dissolve", "xfade"]) diff --git a/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh b/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh new file mode 100755 index 00000000..40f44668 --- /dev/null +++ b/crews/content-producer/skills/expert-video/tools/video-producer/video-producer.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# video-producer.sh — video-producer 工具 wrapper(薄转发,子命令范式;expert-video 包内工具) +# 让 agent 用 `video-producer <子命令> [参数...]` 走 PATH,零路径拼接。 +# 子命令即 scripts/ 下同名 .py,wrapper 转发到对应脚本,不改语义。 +# 子命令入出参见本工具 SKILL.md;制作流程(阶段链 / 两闸门 / 各类型 workflow) +# 见 expert-video 包内 SKILL.md 与 workflows/。产物文件存在性即 checkpoint(不引状态机)。 +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:?用法: video-producer <子命令> [参数...]}" +shift +case "$SUBCMD" in + -h|--help|help) + cat <<'HELP' +video-producer — 视频制作原子能力(wrapper,expert-video 包内工具) + +用法: + video-producer <子命令> [参数...] 跑对应阶段的原子脚本 + video-producer help 列可用子命令 + +流程: + 通用制作流程(expert-video SKILL.md 的 Stage 0→14 + 两闸门)是做**任何**视频都要遵循的基准, + 不是"没指定类型时的备选"。Brief 指定 workflow 时,先读包内 workflows/.md, + 按其阶段裁剪调用下列子命令;未指定时只按通用制作流程走,由 intent-router 定档位。 + +子命令(按阶段序): + intent-router Stage 1 意图路由 → 三档脚本模板(故事讲述型/纯画面动效型/蒙太奇剪接型) + reference-concepts 可选 吃甲方给的参考拆解报告出 2–3 差异化概念 + story-develop Stage 2 idea → 故事(分场) + script-write Stage 3 故事 → 分场剧本(含 enhancement_cues + delivery_cues) + script-self-eval Stage 3 脚本自评 N 维打分 + storyboard-build Stage 4 剧本 → 镜头表 + shot-decompose Stage 5 每镜拆首尾帧 + 运动描述 + variation_type + character-register Stage 6 角色三视图 + static/dynamic features 拆分 + slot-plan Stage 7 素材 slot 规划 + asset-resolve Stage 8 按 slot 拉素材(Fast path 人核缩略图) + slideshow-risk Stage 9 六维幻灯风险打分(pre-compose 闸门) + delivery-promise-lock Stage 9 交付承诺八类锁定 + render-shot Stage 10 按 slot 渲染(AIGC i2v / 静图) + motion-graphics Stage 10 程序化逐帧动态图形(声明式 spec;产品段动效/标题动画,与 render-shot 并列的第二条渲染路径) + mix-audio Stage 11 旁白(awk-tts)+ BGM + 字幕 + narration-align Stage 11 旁白字级时间戳对齐(整段 narration.mp3 模式;复用 awk-tts 原生时间戳,缺失回退火山 ASR) + narration-layout Stage 11 逐句旁白排布 + 防重叠守卫 + 越界断言 + SRT + 可选混音(逐句 mp3 模式) + clip-trim Stage 12 精确切素材段(入点/出点/倍速/归一化/调色/多窗/定帧缓推) + audio-mix Stage 12 多轨混音(每轨独立延时、音量与淡入淡出) + timeline-compose Stage 12 按时间轴 JSON 合成片段(内部调 clip-trim + audio-mix) + scene-compose Stage 12 单 Scene 分段合成(片段+旁白+对白 → 一个 Scene 片段) + assemble Stage 12 按镜顺序拼接成片 + 转场 + 规格归一化 + 守卫断言(--manifest/--verify-fps/--expect-durations) + add-silent-audio Stage 12 给无音频的视频片段补静音音轨(concat 前置) + make-outro Stage 12 片尾制作(形象图+黑边+烧字幕+静音轨 → 标准比例片尾) + motion-audit Stage 13 motion_led 抽查(补公共 video-review) + normalize Stage 13c 响度归一化到 -14 LUFS(**必跑**) + make-cover Stage 14 封面(siliconflow-img-gen,必含封面主文案) + +后期处理(可选,全部干湿分离:输出落 _<处理名>.mp4,不覆盖输入): + burn-srt libass 把 SRT 硬烧进画面(甲方要字幕时) + duck sidechaincompress 让旁白触发 BGM 自动压低(要专业混音且可分轨时) + denoise afftdn / arnndn 去环境噪声(仅甲方素材音质差时) + interp minterpolate 补帧到 30/60fps(仅低 fps 源材) + +闸门不是子命令——GATE A(Stage 6 后文本闸门)与 GATE B(Stage 9 后素材闸门)由 agent +按 expert-video SKILL.md 执行:呈交摘要 → 结束本轮回复 → 等甲方(main agent 或用户)逐闸门批准。 + +产物文件存在性即 checkpoint:每个子命令先查产物文件是否存在,存在则 load 不重生成。 +HELP + ;; + *) + SCRIPT="$SCRIPT_DIR/scripts/${SUBCMD}.py" + if [ ! -f "$SCRIPT" ]; then + echo "未知子命令: $SUBCMD" >&2 + echo "用 video-producer help 查可用子命令" >&2 + exit 1 + fi + exec python3 "$SCRIPT" "$@" + ;; +esac diff --git a/crews/content-producer/skills/expert-video/workflows/collage-broll.md b/crews/content-producer/skills/expert-video/workflows/collage-broll.md new file mode 100644 index 00000000..ca65c90d --- /dev/null +++ b/crews/content-producer/skills/expert-video/workflows/collage-broll.md @@ -0,0 +1,262 @@ +# Workflow:Collage B-roll(纸拼贴组装动画) + +Brief 里写 `workflow: collage-broll`,或甲方要"把这句口播做成拼贴 B-roll""纸拼贴动画""半调拼贴"时使用。把一句约 5 秒的口播压成一个 sharp visual idea,再做成高级编辑风纸拼贴组装动画。 + +本文是**通用制作流程在纸拼贴 B-roll 上的细化**(三道闸门、隐喻与静帧规范、Gate 3 批量调度),不替代通用流程;原子能力、护栏、工作区与交付约定照 `expert-video` 的 SKILL.md 执行。 + +## 三道闸门 + +| 闸门 | 停在哪 | 交付给甲方看什么 | +|------|--------|-----------------| +| Gate 1 隐喻确认 | 只设计视觉隐喻,**不生成图片、不生成视频、不调任何视频模型** | 每条的核心意思、情绪、一句话视觉命题、3–6 个关键物件、建议底色与点色、预期组装顺序 | +| Gate 2 静帧确认 | 隐喻确认后才写 visual spec 与 imagegen prompt,用 `siliconflow-img-gen` 生成静帧 | 带编号的静帧 contact sheet + `gate2-qa.md` | +| Gate 3 视频生成 | 静帧确认后不再问用哪个模型,直接调 `collage-broll gate3` 批量跑 i2v | 逐条 contact sheet + 末帧对照 + `gate3-qa.md` + `video-review` 结论 | + +- 每道闸门都要**结束本轮回复**等甲方批;甲方只确认部分编号时,只让通过的条目进下一道。 +- Gate 1 / Gate 2 分别对应通用制作流程的 GATE A(文本)/ GATE B(素材)语义:付费生成前必停。 +- 甲方已在 Brief 中代理批准某道闸门时,把批准范围记进对应 QA 文件后继续。 + +## 成功标准 + +- 一句话只表达一个清晰隐喻;不要把文稿逐字放进画面。 +- 一条文稿控制在 3–6 个关键物件;元素过多语意变弱,i2v 组装也不稳定。 +- 同一批画面有统一设计语言,但**不强制全部蓝底**。 +- 背景是强烈、平坦、均匀的色场,可按语意变化。 +- 主体以黑白 halftone photographic cut-outs 为骨架;关键卡片、按钮、胶片、规则册等允许红、黄、青、橙、紫、奶油白等彩色纸张。 +- 所有纸片有清晰裁切边、奶油白 keyline、低透明度柔和阴影和纸张颗粒。 +- 动作是 assemble-from-empty,不是轻微漂移、晃动或慢 zoom。 +- 无字幕、无口播全文、无 logo、无水印、无 UI。 +- 默认交付 9:16、5 秒、720×1280、无声 MP4。 + +批量隐喻优先形成前后叙事(例如先表现手工消耗与经验流失,再表现规范沉淀与人机分工)。 + +## 不适用 + +- 需要精确控制图层、遮挡、镜头穿越或可编辑时间线 → 改用分层动画方案,并向甲方说明本 workflow 做不到。 +- 只要视频提示词、不要成片 → 直接写 prompt 交付,不走本流程。 +- 需要真实人物产品广告或口播演员 → 走 Narration Video,或只按通用制作流程做(不套类型 workflow)。 +- 甲方明确要可逐层修改的透明素材 → 本 workflow 默认不拆透明图层。 + +## 项目目录 + +自建工作区 `output_videos//`(甲方不指定、不代建): + +```text +/ +├── brief.md # 文稿 + Gate 1 隐喻清单 +├── visual-spec.json # Gate 2 视觉规格 +├── imagegen-prompts.md # Gate 2 Seedream prompt 留档 +├── gen-jobs.json # Gate 3 批量调用清单 +├── gate2-qa.md # 静帧 QA 结论 +├── gate3-qa.md # 视频 QA 结论 +├── still-contact-sheet.jpg # Gate 2 静帧总图 +├── video-contact-sheet-all.jpg # Gate 3 全部成片逐秒抽帧 +├── video-first-frame-all.jpg # 全部成片实际首帧(验证真的从空色场开始) +├── end-frame-comparison-all.jpg # 确认静帧 vs 视频末帧并排 +├── 01-/ +│ ├── gen-prompt.txt # aigc-video-gen --prompt 内容 +│ ├── frames/ +│ │ ├── still.png # Gate 2 确认的完成帧(原图) +│ │ ├── last-frame.png # 统一裁到 720x1280 的尾帧 +│ │ └── first-frame.png # 纯色空首帧(同底色 hex) +│ └── gen-runs/run-v01/ +│ ├── final-5s.mp4 # aigc-video-gen 产物(声画同出) +│ ├── final-5s-noaudio.mp4 # 默认交付版(无声) +│ ├── contact-sheet.jpg +│ ├── video-last-frame.jpg +│ └── end-frame-comparison.jpg +└── 02-/... +``` + +## Phase 1:设计视觉隐喻(Gate 1) + +先把文稿压成一个视觉命题,提取: + +- **核心意思**:观众最终要看懂什么 +- **情绪**:冷静、惊讶、紧迫、豁然开朗、荒诞、反讽 +- **动作动词**:打开、连接、漏掉、装订、归档、点亮、压缩、分叉、组装 +- **可视化隐喻**:机器、时钟、胶片、档案柜、控制台、规则册、漏斗、轨道、棋子 + +输出格式: + +```text +1. 核心意思:经验每次都在重复消耗 + 视觉隐喻:熟练剪辑师围着巨大的胶片时钟逐帧裁切,时钟走完一圈却只得到一小段成片 + 关键物件:胶片时钟、剪辑师、剪刀、短胶片 + 色彩:焦橙底,奶油白与浅青点色 + 组装顺序:时钟 → 人物与剪刀 → 胶片 → 最终短输出 +``` + +输出后停下等确认。 + +## Phase 2:生成彩色拼贴静帧(Gate 2) + +### visual-spec.json + +```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) + +```bash +siliconflow-img-gen --prompt "<下面整段>" --image-size 1600x2848 --out-dir /01-/frames/ +``` + +Prompt 用英文(Seedream 对英文响应更好): + +```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` 范围。 + +### 静帧 QA + +检查:隐喻是否一眼看懂 / 主体是否集中 / 是否有假字、logo、水印、UI / 是否保留足够纯色场便于从空场组装 / 是否 3–6 个清晰大组而非满屏碎片 / 同批是否统一质感但有色彩变化。 + +通过的原图复制到 `/frames/still.png`,拼 contact sheet 后交甲方确认,结论写 `gate2-qa.md`。要求重生部分静帧时,新版 contact sheet 递增命名(`still-contact-sheet-v2.jpg`),保留旧版便于对比。 + +```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:i2v 生成视频(Gate 3) + +### 1. 准备首尾帧 + +```bash +# 尾帧:确认静帧统一裁到 720x1280 +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 的核心) +ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 -frames:v 1 /frames/first-frame.png +``` + +甲方明确要求不从完全空白开始时,首帧才保留一个基础物件。 + +### 2. 写动画 prompt(中文,声画同出) + +动作顺序默认:`基础结构 → 人物或关键卡片 → 连接件 → 动作 → 最终结果`。 + +```text +画面从纯色空场开始,依次滑入 [基础结构] → [人物/卡片] → [连接件] → [动作],最终定格在已确认的完成构图。固定机位,无切镜、无 zoom、无变形。画面无文字、无 logo、无水印、无 UI。音频:纸片滑入的嗒嗒声 + 卡位时的咔嗒声 + 最终定格的短促 BGM 收尾。 +``` + +每条 prompt 明确首帧是空首帧、尾帧是确认过的完成帧;最终构图必须贴近 last-frame,不让模型自由改造尾帧。 + +### 3. 批量生成 + +写 `gen-jobs.json`(每条含 `prompt` / `first_frame` / `last_frame` / `output` / `ratio` / `resolution` / `duration`),然后: + +```bash +collage-broll gate3 --batch /gen-jobs.json +collage-broll gate3 --batch /gen-jobs.json --dry-run # 先看调度计划 +``` + +内部串行逐条调公共 `aigc-video-gen` i2v(视频生成是异步轮询任务,并行会撞平台并发限),候选链 fallback 与 decisions.log 由 `aigc-video-gen` 自带。退出码 2 = 部分 job 失败:只重跑失败条目,已通过的不重跑。 + +开工前先跑 `collage-broll check-setup` 自检 ffmpeg / ffprobe / AWK_API_KEY / 视频平台 key / Python 版本。 + +> `aigc-video-gen` 要求输出相对路径落在 `output_videos/` 下,**调用时 workdir 必须是 Content Producer workspace 根**;i2v 报错先查首尾帧是否真存在、是否 720x1280。 + +### 4. 强制无声交付 + +```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` 作中间产物。甲方明确要带声时直接交付 `final-5s.mp4`。 + +## 视频 QA + +不要只看尾帧,必须检查组装过程与最终落位。 + +```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、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 后必须再跑公共 `video-review`,verdict=pass 才交付: + +```bash +video-review /final-5s-noaudio.mp4 +``` + +视觉 QA 评美与语义,`video-review` 评技术合规(ffprobe 全字段 / 抽帧黑帧扫 / 音频电平 / 时长分辨率一致性),互补不重叠。fail 按 critical 项修或重生对应 job;warn 向甲方复述由其决定。 + +> 默认无声交付时 `audio_absent` warning 是预期,可放行;带声版出 `audio_absent` 是 critical(声画同出该出声没出声),退回重生成。 + +### 常见问题 + +| 症状 | 处理 | +|------|------| +| 首帧边缘提前露出 | 轻微可接受;严格空场需求改用更坚定的 first-frame(纯色 + 边缘 padding) | +| 组装感弱 | 缩短元素数量,prompt 改为明确的逐件"滑入 / 卡位"顺序 | +| 尾帧漂移 | 强化 prompt 里"最终定格在已确认的完成构图"(i2v 的 last-frame 权重高) | +| 出现假字 | 回到静帧重生,不要用视频 prompt 修补 | +| 个别视频失败 | 只重跑对应 job | +| i2v 报错 | 查首尾帧是否 720x1280、是否真存在、workdir 是否 workspace 根 | + +## 交付 + +- 每条 `/gen-runs/run-v01/final-5s-noaudio.mp4`(甲方要带声则 `final-5s.mp4`) +- 每条 contact sheet、批量总 contact sheet、末帧对照图 +- `gate2-qa.md` / `gate3-qa.md` / `video-review` 结论 +- 一句说明每条文稿如何转成视觉隐喻 +- 回报产物**绝对路径** + +成片问题来自 i2v 生成限制(组装感弱 / 尾帧漂移)时直接说明;只有需要精确图层控制时才建议换方案,并向甲方报清代价。 diff --git a/crews/content-producer/skills/expert-video/workflows/narration-video.md b/crews/content-producer/skills/expert-video/workflows/narration-video.md new file mode 100644 index 00000000..ab237260 --- /dev/null +++ b/crews/content-producer/skills/expert-video/workflows/narration-video.md @@ -0,0 +1,59 @@ +# Workflow:Narration Video(口播类视频) + +Brief 里写 `workflow: narration-video`,或 Brief 交付了口播文案 / 真人口播录音时使用。本文是**通用制作流程在口播类视频上的细化**:声画实现套路、阶段裁剪与验收补充,不替代通用流程——阶段链、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 + +## 类型定义 + +以**人声讲述**为主干的视频。口播文案是甲方(main agent 或用户)的策略产物,已定稿交付;我负责声画实现——配音或录音处理、字级时间戳对齐、按语义配画面、字幕、BGM、成片与封面。 + +## 三种输入形态 + +| 形态 | 甲方交付 | 我做什么 | +|------|---------|-----------| +| TTS 旁白 + 画面 | `voiceover.md`(口播终稿)+ 素材(可选) | `awk-tts` 生成旁白(音色按 Brief)→ `narration-align` 拿字级时间戳 → 按时间戳切配画面 → 字幕 → BGM | +| 真人口播录音 + 画面 | 录音文件绝对路径 + 素材(可选) | ASR 拿时间戳(Stage 11 场景 D)→ 按时间戳排画面 → 字幕 → 音质差时降噪 → BGM ducking | +| 真人出镜口播录像 | 录像素材绝对路径 | 加字幕、BGM、片头片尾、包装、封面;语义级高光剪辑与去口气词交回 main 的 `talking-head-cut` | + +## 硬边界 + +- **口播文案原样落稿锁定**:落 `script/script.md`,不重写、不"顺手优化"措辞、不增删卖点。 +- 发现文案有问题(超时长带、合规风险、抽象到无法配画面)→ **报甲方**,不自行改。 +- 模式 B(用户直接对话)下用户只给大意时,可代拟文案,但必须发用户确认,定稿后才算 `voiceover`。 +- 语义级剪辑(去口气词、智能剪重点)归 main 的 `talking-head-cut`;我只做几何级修整(切段/拼接/混音/烧字幕/补轨)。 + +## 阶段裁剪(对通用制作流程的细化) + +| 阶段 | 本 workflow 的做法 | +|------|--------------------| +| Stage 0 | 除 Brief 外,必须确认口播文案 / 录音**已到位**:绝对路径存在、可解码、时长可读;缺 → 向甲方要,不开工 | +| Stage 1 | 档位一般为故事讲述型;纯画面动效型不适用(口播是主干) | +| Stage 2–3 | 跳过 `story-develop` / `script-write`;口播稿原样落 `script/script.md` | +| Stage 3b | `script-self-eval` 只做检查,不改写:总字数 vs 目标时长(按 6–8 字/秒折算)、句长、合规敏感词、是否存在无法配画面的抽象段 | +| Stage 4 | 分镜按口播段落切:每段"讲什么 → 看什么"写清;不写不可见 | +| Stage 5 | 画面全来自现成素材时跳过;需要 AIGC 补画面时保留 | +| Stage 6 | 无 AIGC 角色时跳过 | +| Stage 7–8 | slot 按口播语义规划;甲方给了素材先入库校验(可解码/规格/授权),缺口才补搜 | +| Stage 9a | 幻灯风险重点查"一句口播一张静图"的幻灯片感 | +| Stage 9b | 交付承诺必须含音画同步与字幕样式 | +| Stage 11 | **核心阶段**:场景 B(TTS 一次性生成 + 字级时间戳)或场景 D(甲方录音 → ASR 时间戳);字幕必须来自对齐后的时间轴 | +| Stage 12 | `timeline-compose` 按字级时间戳对齐:每句口播不得越过对应镜头边界,连续口播保留呼吸间隔;切旁白段用 `clip-trim --pre-buffer 0.5` 防吞首字。甲方要求逐句 TTS(每句独立 mp3)时改走 `narration-layout`(对齐镜头起点 + 防重叠守卫 + 越界断言 + SRT + 混音一步),拼接用 `assemble --manifest --verify-fps` | +| Stage 13 | `video-review` + `motion-audit` + **响度归一化必跑** | +| Stage 14 | 封面主文案来自 Brief;口播金句作候选时需 Brief 允许 | + +## 声音规范 + +- **音色**:按 Brief 指定;未指定时选与内容气质匹配的音色,把备选 + 置信度 + 理由记 `script/decisions.json`。 +- **语速**:默认 6–8 字/秒;Brief 指定优先。 +- **字幕**:来自对齐时间轴;烧录前查目标画幅安全区,不落画面中部、不遮主体。 +- **BGM**:口播下 ducking(人声主、BGM 从);免版税曲库(`bgm-library`)优先。 +- **录音质量差**:走 `video-producer denoise`(afftdn),只降噪不改内容。 + +## 验收检查 + +1. 口播与甲方交付逐字一致(除甲方书面同意的修改)。 +2. 音画同步:每句口播对应画面不越界,无黑屏空转、无长静帧。 +3. 字幕与口播一致、无错字、在安全区内。 +4. 响度归一化(-14 LUFS)已跑并记录。 +5. 无吞字、无爆音;BGM 不盖人声。 +6. `video-review` verdict=pass。 +7. `final-deliver.md` 含音色 / 语速 / BGM 选择与理由、素材来源与授权、各段实际时长、遗留问题。 diff --git a/crews/content-producer/skills/expert-video/workflows/reversal-ad.md b/crews/content-producer/skills/expert-video/workflows/reversal-ad.md new file mode 100644 index 00000000..ab092ad0 --- /dev/null +++ b/crews/content-producer/skills/expert-video/workflows/reversal-ad.md @@ -0,0 +1,144 @@ +# Workflow:Reversal Ad(「万万没想到」式反转植入) + +Brief 里写 `workflow: reversal-ad` 时使用。本文是**通用制作流程在反转植入类视频上的细化**:三段结构、反转手法、素材与口播约束、阶段裁剪,不替代通用流程——原子能力、GATE A/B 闸门纪律、护栏、工作区与交付约定一律照 `expert-video` 的 SKILL.md 执行;本文与通用流程冲突处以本文为准,但闸门与护栏不让步。 + +## 类型定义 + +一整段**视频解说**(影视解说 / 剧情解说 / 纪录片式解说)铺垫,观众以为自己在看内容;到某一帧突然反转插入品宣——猝不及防、万万没想到。笑点与记忆点全部来自「预期落差」,所以反转前必须让观众完全相信这是一条内容视频。两条第一质量轴:**反转幅度足够大、接入足够丝滑**——实践里最常见的失败不是没有反转,而是幅度不够或接入生硬,见「反转幅度与接入丝滑度」。 + +## 三段结构 + +| 段 | 时长占比 | 功能 | 硬要求 | +|----|---------|------|--------| +| 解说正文 | 63%–76% | 讲一个自洽、有冲突、有悬念的故事 | 主悬念一句话可复述,且贯穿到反转点;段尾停在「求助 / 任务 / 待发 / 冲突」节点 | +| 反转过渡 | 3%–13% | 用一句话或一帧把剧情指向产品 | 必须双关或因果可追,不许硬切;口播明写因果 | +| 产品植入 | 15%–27% | 集中讲产品,讲完立即收尾 | 3–4 句单点深打,覆盖 Brief 允许的 ≥3 个价值点,每点有对应画面;片尾 CTA 优先用二次反转剧情化承载 | + +反转点落在总时长 **55%–76%** 之间(集中植入偏前,两段式偏后)。占比是创作约束,不是硬编码参数;Brief 指定时以 Brief 为准。产品植入段末尾可设**二次反转**承载 CTA(见「反转幅度与接入丝滑度」),其时长计入产品植入段,不另立第四段。 + +## 反转手法(四选一,或叠加) + +| 手法 | 机制 | 实现要点 | +|------|------|----------| +| 任务指引式 | 剧情停在求助节点 → 指路者给出答案,答案就是产品 | 「去往 X 就能化解 Y」句式;产品信息用标注贴图/攻略图呈现(箭头 + 关键词),不做广告贴片感 | +| 双关置换式 | 剧情核心意象与产品同名同形 | 意象必须在解说正文里先建立,反转时复用同一意象(沙盘 / 天池 / 秘境之类) | +| 身份彩蛋式 | 主角身份在反转处变成与产品相关的角色 | 身份转换要由剧情自然推出(求职、比赛、任务),不靠旁白硬说 | +| 戏中戏式 | 剧情内的媒介(录像机屏幕、照片、窗外、手机屏)先出现产品画面 | 口播仍讲剧情句,画面先行;随后全屏切产品段 | + +## 反转幅度与接入丝滑度(第一质量轴) + +实践中最常见的失败不是没有反转,而是**反转幅度不够大、或接入生硬**。两轴缺一不可: + +- **幅度大**:反转前后两个世界的距离拉到最远——题材跳(现实求助 / 悬念事件 ↔ 商业产品)、身份跳(普通人 ↔ 产品相关角色)、场景跳、情绪跳。铺垫段演得越认真、越沉浸,反转越强;铺垫段禁止自嘲、禁止对镜头挤眼、禁止流露任何「广告感」。 +- **丝滑接入**:接入点必须由剧情自身逻辑自然推出——自问自答、因果链、意象复用。观众事后的反应应是「原来如此,说得通」,而不是「怎么突然打广告」。接入句的因果必须一句话可复述。 +- **二次反转(可选,强烈推荐)**:片尾互动号召(转发 / 点赞 / 评论)不做广告口号,种进剧情动机里——观众完成互动时感觉自己在推进剧情,而不是在点广告。 + +✅ 正例(真人口播爆款,借结构不照搬措辞):一个人拍视频「我丢了 2000 块钱,大家帮我找找,找到必有重谢」——一转:「我答谢你什么呢?答谢你带你去呼伦贝尔旅游」,身份揭晓:他其实是呼伦贝尔地接导游,宣传的是旅游线路——二转:「这 2000 块对我真的很重要,看到的哥哥姐姐帮我转发一下」,转发号召成了「找钱」剧情的自然延伸。三个可复制点:铺垫严肃可信(现实求助、零广告感)→ 落差巨大;「答谢你什么呢」自问自答桥接两个世界 → 接入丝滑;CTA 由剧情动机给出 → 转发不是打广告。 + +❌ 反例: + +- 铺垫段本身松垮、观众开场就闻到广告味——反转再大也无效。 +- 反转靠硬切加口号(「万万没想到,这就是 XX 品牌」),剧情内无因果——幅度大但接入生硬。 +- 片尾 CTA 喊「求转发求点赞一键三连」——与剧情割裂,浪费二次反转位。 + +GATE A 交审脚本时必须附四问答案(`script-self-eval` 同查): + +1. 首次观看的观众,反转点之前会认真相信这是一条纯内容视频吗? +2. 反转瞬间能让观众脱口而出「万万没想到」吗(幅度)? +3. 接入句的因果能一句话复述吗(丝滑度)? +4. CTA 也是剧情的一部分吗(设了二次反转时)? + +> 正例为真人口播场景,其第二人称口语不受本文口播规范约束;由我代笔第三人称解说体时,CTA 句式仍按口播规范,但 CTA **动机必须由剧情给出**。 + +## 叙事与植入规则 + +- **单线闭环**:主角动机一句话说清并在片内闭环;素材撑不住就改写,不留断头线。 +- **节拍 ≤ 6**:每节拍一句,句间因果可追。 +- **钩连句**:转生、化身、置换等转折必须用口播明写因果,并用同色、同物或同动作的画面衔接。 +- **意象桥**:产品段第一镜必须复用解说正文的核心意象;禁止无关风景空镜硬切。 +- **反转前零产品提及**:不插 punch 句、不散落卖点、不出现品牌暗示(含 logo、包装、界面)。 +- **收束**:产品段讲完立即收尾,不加无信息量的仪式句或氛围空镜。 +- **CTA 剧情化**:片尾行动号召优先由剧情二次反转承载(动机由剧情给出),不用空洞广告口号;见「反转幅度与接入丝滑度」。 +- **音效**:可在反转瞬间用 whoosh 等音效标记剧情→产品切换,但不得用音效替代因果衔接。 +- **合规**:正片零 URL、零域名、零联系方式;官网、仓库、价格、活动入口只写进 `final-deliver.md` 交甲方(发布说明由甲方拟)。产品能力只讲 Brief 允许的范围,不承诺收益数字。 + +## 口播(旁白) + +- 甲方交付 `voiceover.md` 时**原样落稿锁定**(Stage 3 不重写策略文案,仍跑 `script-self-eval` 做检查)。 +- 未交付时由我写,GATE A 交审。默认规范:第三人称解说体;避免问句、感叹号、第二人称(你/您/家人们)与促销信号词(「接下来介绍」「这是我们的」);句长 10–15 字,语速 7–8 字/秒。 +- 真人口播录音(甲方提供)走 Stage 11 场景 D:ASR 拿时间戳后按时间戳排素材,不重配旁白。 + +## 素材 sourcing + +来源模式只允许以下四类(含混合),每段素材都必须能追溯到来源模式与授权记录: + +1. **open_license_footage**:首选 **Blender 开源电影**——官网直链 `studio.blender.org/films/…`,无需登录,协议均为 CC BY。已验证可用片库:Spring / Sintel / Caminandes 系列 / Tears of Steel / Big Buck Bunny / Coffee Run。逐部记录原片 URL、许可证与署名要求;**发布简介必须带原片署名**——署名文案写入 `final-deliver.md` 交甲方并入发布说明。片库以外的片源逐案核实许可证允许商用与改编,不得把"网上能下载"等同于可用。 +2. **user_provided**:用户直供现成影视片段。入库**三查**:① `ffprobe` 全片 decode 校验(整片解码,不只读文件头,防尾部损坏);② 记录分辨率、帧率、时长、音轨;③ 来源与授权背景向用户确认,原样记入 `final-deliver.md`。署名按用户提供的信息如实标注,不猜测、不做授权背书;版权风险由甲方确认承担,我只做技术处理。入库副本落 `raw_materials/`。 +3. **aigc**:按 Brief 风格边界生成(公共 `aigc-video-gen`)。**每段 3–15s**;prompt 必须含**画面描述 + 音频描述**——AIGC 声画同出,源文件自带音频可作环境音床(见护栏 5)。产物存 `/generations/`(天然满足 `aigc-video-gen` 的 `output_videos/` 路径约束),每段附 metadata(prompt、模型、生成时间)。发布时按平台要求勾「AI 生成」标注——标注要求写入 `final-deliver.md` 交甲方。 +4. **mixed**:以上混合,每段素材独立追溯到各自的模式与授权记录。 + +通用入库要求: + +- 逐条按来源模式完成入库检查:`ffprobe` 全片 decode 校验(不只读文件头),记录分辨率、帧率、时长、音轨。 +- 建素材清单与候选区索引;候选区文件名能反查源素材时间点。 +- **素材四查**:URL / 域名、旧品牌名或水印、残缺元素、字幕遮挡。发现风险优先换干净素材窗口,裁切只能作为确认安全后的次选。 +- 素材支撑度不足时回到 Brief owner,不为了凑成片编造叙事。 + +## Brief 必备字段 + +缺任一项先向 Brief owner 澄清,不自行补品牌事实、授权或许可证: + +| 字段 | 要求 | +|------|------| +| platform | douyin / wx_channel / xhs 等,用于画幅、时长带与合规边界 | +| core_message | 本条必须传达的核心信息 | +| product_points | 产品/服务事实、允许讲的能力、禁用承诺(只以 Brief 为准,不内置品牌事实) | +| implant_cta | Brief「业务植入与 CTA」字段:植入位置与方式、内容与业务的衔接句要求、CTA 主目标与句式;未给时按本 workflow 默认(反转点 55%–76%、植入段单点深打、片尾一个主行动且优先二次反转剧情化承载),并在 GATE A 说明 | +| twist_variant | 任务指引式 / 双关置换式 / 身份彩蛋式 / 戏中戏式;未指定时由我据素材与故事选定,并在 GATE A 说明理由 | +| story_source | 解说正文的故事来源(开源片名 / 用户素材 / AIGC 生成;开源片名优先 Blender 开源电影片库,见「素材 sourcing」) | +| voiceover | 甲方交付口播终稿路径;未交付时写明由我起草 | +| source_mode | open_license_footage / user_provided / aigc / mixed | +| assets | 素材绝对路径、来源 URL、许可证、授权确认记录 | +| form | 横竖屏、时长带、画面风格、配音音色与 BGM 倾向 | +| subtitle_style | 是否烧字幕、样式与安全区;未指定按平台常规可读性处理 | +| gates | GATE A/B 批准人;甲方代理批准时写明批准范围 | +| acceptance | 交付物、验收标准、遗留问题记录要求 | + +## 阶段裁剪(对通用制作流程的细化) + +| 阶段 | 本 workflow 的做法 | +|------|--------------------| +| Stage 1 | 档位固定 **故事讲述型(narrative)** | +| Stage 2 | 甲方交付口播时跳过;未交付时故事梗概必须写明三段结构与反转手法 | +| Stage 3 | `script/script.md` 按段标注(解说 / 反转 / 植入)+ 每段时长占比 + 反转点时间码;自评过「反转幅度与接入丝滑度」四问,GATE A 随脚本交答案 | +| Stage 4 | 镜头表每镜标所属段;反转点前后各一镜写明衔接意象(意象桥) | +| Stage 6 | 现成素材轨且无 AIGC 角色时跳过角色三视图 | +| Stage 7–8 | slot 按段规划,解说段 slot 数最多;植入段 slot 必须能承载 Brief 的价值点 | +| Stage 9b | delivery_promise 中记录反转点位置与植入段占比承诺 | +| Stage 11 | 场景 B(旁白一次性 TTS + 对齐)或 D(甲方录音);BGM 优先沿用素材原生配乐轨并 ducking | +| Stage 12 | 解说段与植入段允许色调对比(冷暗 → 明亮实景/界面),但必须用钩连句 + 意象桥缝合 | +| Stage 14a | 封面优先用素材高情绪帧直用;封面主文案来自 Brief,无标题平台用核心传达 | + +## 制作护栏 + +1. **规格一次锁定**:横竖屏、目标时长、分辨率与帧率由 Brief 决定;不为"看起来高级"擅自放大素材。需要统一规格时先确认素材源质量与环境约束,再写入 timeline 计划与决策日志。 +2. **帧率一致性**:混排来源素材时先确认各段帧率;不一致必须在切片时用 `clip-trim --normalize WxH@FPS` 统一(或 `assemble` 归一化参数),不得不同帧率直接 concat 交付。拼接一律带 `assemble --verify-fps`(断言成片帧率)+ `--expect-durations`(逐段时长 vs 计划 ± 容差),输出后核对音画同步与段边界。 +3. **旁白排布**:逐句 TTS 模式(解说/反转植入类默认)走 `narration-layout`——实测镜头时长累积起点、每句对齐镜头起点、防重叠守卫(min_gap)、逐句与末句越界断言、SRT 与混音一步产出;**守卫断言失败改计划(镜头时长/文案),不放宽容差硬过,更不手写排布脚本**。整段旁白模式走 `mix-audio` + `narration-align` 的实测时长 / 字级时间戳。两种模式都不用文本长度估算;每句旁白不得越过对应镜头边界(确需跨镜的桥句在 plan 里显式 `allow_spill`);连续旁白保留呼吸间隔,累计漂移可追溯到 `audio/abs_starts.json` 的每段实测时长。 +4. **字幕安全区**:`subtitles.srt` 必须来自对齐后的口播时间轴;烧录前检查目标画幅安全区,避免字幕落到画面中部或关键主体上。字幕样式由 Brief 决定,不硬编码项目个案参数。 +5. **AIGC 原声**:AIGC 声画同出素材如含有效环境音,可作低音量音床并在口播下 ducking;无有效音轨时补静音,不用噪声填充。 +6. **决策审计**:素材取舍、规格、音色、字幕样式、反转手法选择、fallback 与弃用中间产物都写入 `script/decisions.json` 或 `final-deliver.md`。 + +机器资源限制、编码线程数、分辨率上限、低载参数属部署环境差异,读 Content Producer workspace 的 `MEMORY.md` 或 Brief 的 `environment_constraints`,不写进本 workflow。 + +## 验收检查 + +1. 正片零 URL、零域名、零联系方式。 +2. 品牌名、产品名、能力表述与 Brief 一致;无收益承诺。 +3. 叙事单线闭环,节拍 ≤ 6,转折因果可追。 +4. 反转前零产品提及;意象桥成立(产品段第一镜复用剧情核心意象)。 +5. 反转两轴达标:铺垫段可信为纯内容视频且与产品世界落差足够(幅度)、接入句因果一句话可复述(丝滑);设二次反转时 CTA 是剧情动机的延伸。 +6. 产品段占比与反转点位置落在承诺区间,且与选定手法一致。 +7. 每段素材来源与授权可追溯;Blender 署名、用户素材授权确认、AIGC 标注要求均已写入 `final-deliver.md`。 +8. 口播、字幕、画面、音效与 Brief / `voiceover.md` 一致。 +9. `video-review` verdict=pass;未通过不得交付。 +10. `final-deliver.md` 含素材来源与协议、口播终稿、各段实际时长、自检结果、弃用产物与遗留问题。 diff --git a/crews/content-producer/skills/manim-explainer/SKILL.md b/crews/content-producer/skills/manim-explainer/SKILL.md deleted file mode 100644 index 0ac931b9..00000000 --- a/crews/content-producer/skills/manim-explainer/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: manim-explainer -description: Build reusable Manim explainers for technical concepts, graphs, system - diagrams, and product walkthroughs, then hand off to the wider video stack if needed. - Use when the user wants a clean animated explainer rather than a generic talking-head - script. -metadata: - openclaw: - emoji: 🎬 - requires: - bins: - - python3 - - manim - - ffmpeg ---- - -# Manim Explainer - -Use Manim for technical explainers where motion, structure, and clarity matter more than photorealism. - -## When to Activate - -- the user wants a technical explainer animation -- the concept is a graph, workflow, architecture, metric progression, or system diagram -- the user wants a short product or launch explainer for X or a landing page -- the visual should feel precise instead of generically cinematic - -## Tool Requirements - -- `manim` CLI for scene rendering -- `ffmpeg` for post-processing if needed -- `video-edit assemble` for combining rendered video with TTS audio -- `awk-tts` for voiceover generation - -## Default Output - -- short 16:9 MP4 -- one thumbnail or poster frame -- storyboard plus scene plan - -## Workflow - -1. Define the core visual thesis in one sentence. -2. Break the concept into 3 to 6 scenes. -3. Decide what each scene proves. -4. Write the scene outline before writing Manim code. -5. Render the smallest working version first. -6. Tighten typography, spacing, color, and pacing after the render works. -7. Hand off to the wider video stack only if it adds value. - -## Scene Planning Rules - -- each scene should prove one thing -- avoid overstuffed diagrams -- prefer progressive reveal over full-screen clutter -- use motion to explain state change, not just to keep the screen busy -- title cards should be short and loaded with meaning - -## Network Graph Default - -For social-graph and network-optimization explainers: - -- show the current graph before showing the optimized graph -- distinguish low-signal follow clutter from high-signal bridges -- highlight warm-path nodes and target clusters -- if useful, add a final scene showing the self-improvement lineage that informed the skill - -## Render Conventions - -- default to 16:9 landscape unless the user asks for vertical -- start with a low-quality smoke test render -- only push to higher quality after composition and timing are stable -- export one clean thumbnail frame that reads at social size - -```bash -# 冒烟测试(低质量,优先用此验证构图) -manim-explainer .py low ./output - -# 中等质量预览 -manim-explainer .py medium ./output - -# 正式输出(高质量) -manim-explainer .py high ./output -``` - -脚本自动完成:渲染 → 定位 MP4 → 导出第 2 秒封面帧,最后输出 JSON: -```json -{"ok": true, "video": "./output/scene_Class_low.mp4", "thumbnail": "./output/scene_Class_thumbnail.png"} -``` - -## Reusable Starter - -Use [assets/network_graph_scene.py](assets/network_graph_scene.py) as a starting point for network-graph explainers. - -Example smoke test: - -```bash -manim-explainer assets/network_graph_scene.py NetworkGraphExplainer low ./output -``` - -## Output Format - -Return: - -- core visual thesis -- storyboard -- scene outline -- render plan -- any follow-on polish recommendations - -## Related Skills - -- `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/manim-explainer/assets/network_graph_scene.py b/crews/content-producer/skills/manim-explainer/assets/network_graph_scene.py deleted file mode 100644 index 74651a29..00000000 --- a/crews/content-producer/skills/manim-explainer/assets/network_graph_scene.py +++ /dev/null @@ -1,52 +0,0 @@ -from manim import DOWN, LEFT, RIGHT, UP, Circle, Create, FadeIn, FadeOut, Scene, Text, VGroup, CurvedArrow - - -class NetworkGraphExplainer(Scene): - def construct(self): - title = Text("Connections Optimizer", font_size=40).to_edge(UP) - subtitle = Text("Prune low-signal follows. Strengthen warm paths.", font_size=20).next_to(title, DOWN) - - you = Circle(radius=0.45, color="#4F8EF7").shift(LEFT * 4 + DOWN * 0.5) - you_label = Text("You", font_size=22).move_to(you.get_center()) - - stale_a = Circle(radius=0.32, color="#7A7A7A").shift(LEFT * 1.6 + UP * 1.2) - stale_b = Circle(radius=0.32, color="#7A7A7A").shift(LEFT * 1.2 + DOWN * 1.4) - bridge = Circle(radius=0.38, color="#21A179").shift(RIGHT * 0.2 + UP * 0.2) - target = Circle(radius=0.42, color="#FF9F1C").shift(RIGHT * 3.2 + UP * 0.7) - new_target = Circle(radius=0.42, color="#FF9F1C").shift(RIGHT * 3.0 + DOWN * 1.4) - - stale_a_label = Text("stale", font_size=18).move_to(stale_a.get_center()) - stale_b_label = Text("noise", font_size=18).move_to(stale_b.get_center()) - bridge_label = Text("bridge", font_size=18).move_to(bridge.get_center()) - target_label = Text("target", font_size=18).move_to(target.get_center()) - new_target_label = Text("add", font_size=18).move_to(new_target.get_center()) - - edge_stale_a = CurvedArrow(you.get_right(), stale_a.get_left(), angle=0.2, color="#7A7A7A") - edge_stale_b = CurvedArrow(you.get_right(), stale_b.get_left(), angle=-0.2, color="#7A7A7A") - edge_bridge = CurvedArrow(you.get_right(), bridge.get_left(), angle=0.0, color="#21A179") - edge_target = CurvedArrow(bridge.get_right(), target.get_left(), angle=0.1, color="#21A179") - edge_new_target = CurvedArrow(bridge.get_right(), new_target.get_left(), angle=-0.12, color="#21A179") - - self.play(FadeIn(title), FadeIn(subtitle)) - self.play( - Create(you), - FadeIn(you_label), - Create(stale_a), - Create(stale_b), - Create(bridge), - Create(target), - FadeIn(stale_a_label), - FadeIn(stale_b_label), - FadeIn(bridge_label), - FadeIn(target_label), - ) - self.play(Create(edge_stale_a), Create(edge_stale_b), Create(edge_bridge), Create(edge_target)) - - optimize = Text("Optimize the graph", font_size=24).to_edge(DOWN) - self.play(FadeIn(optimize)) - self.play(FadeOut(stale_a), FadeOut(stale_b), FadeOut(stale_a_label), FadeOut(stale_b_label), FadeOut(edge_stale_a), FadeOut(edge_stale_b)) - self.play(Create(new_target), FadeIn(new_target_label), Create(edge_new_target)) - - final_group = VGroup(you, you_label, bridge, bridge_label, target, target_label, new_target, new_target_label) - self.play(final_group.animate.shift(UP * 0.1)) - self.wait(1) diff --git a/crews/content-producer/skills/manim-explainer/manim-explainer.sh b/crews/content-producer/skills/manim-explainer/manim-explainer.sh deleted file mode 100755 index 757c885a..00000000 --- a/crews/content-producer/skills/manim-explainer/manim-explainer.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# manim-explainer.sh — manim-explainer 顶层 wrapper(薄转发) -# 让 agent 用 `manim-explainer ` 走 PATH,零路径拼接。 -# 内部转发到 scripts/render-manim.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/render-manim.sh" "$@" diff --git a/crews/content-producer/skills/manim-explainer/scripts/render-manim.sh b/crews/content-producer/skills/manim-explainer/scripts/render-manim.sh deleted file mode 100755 index a37de10e..00000000 --- a/crews/content-producer/skills/manim-explainer/scripts/render-manim.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# render-manim.sh — Manim 场景渲染 + 封面帧导出 -# -# Usage: render-manim.sh [quality] [output_dir] -# quality : low(冒烟测试,默认)| medium(预览)| high(正式输出) -# output_dir: 输出目录(默认 ./output) -# -# 输出: -# /__.mp4 -# /__thumbnail.png -# stdout 最后一行:JSON {"ok":true,"video":"...","thumbnail":"..."} - -set -euo pipefail - -SCENE_FILE="${1:?Usage: render-manim.sh [quality] [output_dir]}" -CLASS_NAME="${2:?Missing ClassName}" -QUALITY="${3:-low}" -OUTPUT_DIR="${4:-./output}" - -[[ -f "$SCENE_FILE" ]] || { echo "ERROR: 场景文件不存在: $SCENE_FILE"; exit 1; } - -case "$QUALITY" in - low) Q_FLAG="-ql" ;; - medium) Q_FLAG="-qm" ;; - high) Q_FLAG="-qh" ;; - *) echo "ERROR: quality 必须是 low/medium/high"; exit 1 ;; -esac - -mkdir -p "$OUTPUT_DIR" -SCENE_BASE=$(basename "$SCENE_FILE" .py) - -# 使用临时 media 目录,避免污染工作目录 -MEDIA_DIR=$(mktemp -d) -trap "rm -rf '$MEDIA_DIR'" EXIT - -echo ">>> 渲染: $CLASS_NAME ($QUALITY)" -manim "$Q_FLAG" "$SCENE_FILE" "$CLASS_NAME" --media_dir "$MEDIA_DIR" - -# 找到渲染输出的 MP4 -VIDEO_PATH=$(find "$MEDIA_DIR/videos" -name "*.mp4" | head -1) -[[ -n "$VIDEO_PATH" ]] || { echo "ERROR: 未找到渲染输出文件"; exit 1; } - -FINAL_VIDEO="$OUTPUT_DIR/${SCENE_BASE}_${CLASS_NAME}_${QUALITY}.mp4" -cp "$VIDEO_PATH" "$FINAL_VIDEO" -echo ">>> 视频: $FINAL_VIDEO" - -# 导出封面帧(第 2 秒) -THUMBNAIL="$OUTPUT_DIR/${SCENE_BASE}_${CLASS_NAME}_thumbnail.png" -ffmpeg -y -i "$FINAL_VIDEO" -ss 2 -frames:v 1 "$THUMBNAIL" -loglevel error -echo ">>> 封面帧: $THUMBNAIL" - -echo "{\"ok\":true,\"video\":\"$FINAL_VIDEO\",\"thumbnail\":\"$THUMBNAIL\"}" diff --git a/crews/content-producer/skills/video-producer/SKILL.md b/crews/content-producer/skills/video-producer/SKILL.md deleted file mode 100644 index a0b8aa22..00000000 --- a/crews/content-producer/skills/video-producer/SKILL.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -name: video-producer -description: 视频制作全能工具,两种用法——(A) 端到端生产:从零做一支完整视频,出脚本、分镜、机位一致性、素材匹配、闸门、渲染、自检、交付;(B) 给定素材剪辑:已有几个片段要拼一下、给一个片段配音合成、剪辑烧字幕等,直接用 Stage 12 工具箱(assemble/clip-trim/audio-mix/timeline-compose/scene-compose/add-silent-audio/make-outro)。 -metadata: - openclaw: - emoji: 🎬 - requires: - bins: - - python3 - - ffmpeg - - ffprobe - env: - - AWK_API_KEY - primaryEnv: AWK_API_KEY ---- - -# 视频制作与片段修整(video-producer) - -## 双模式 - -本技能有两种用法,agent 据用户请求判断走哪条: - -**模式 A:端到端生产**——用户给主题/关键词/已有脚本/已有素材中的任一组合,要求从零做一支完整视频。走 Stage 0→14 全流程:意图路由 → 故事 → 剧本 → 分镜 → 机位 → 素材 → 闸门 → 渲染 → 自检 → 交付。另可接收 **main agent 喂入的 viral-chaser 追爆报告**(作为 brief 的一部分,本技能不做视频下载/转写/抽帧——那是 viral-chaser 的活)。 - -**模式 B:给定素材剪辑**——不涉及从零开剧本,编辑已有素材,直接用 Stage 12 工具箱(见下方"Stage 12 工具箱"段): - -- 几个片段拼一下 → `assemble`(自动统一分辨率/帧率/音频格式 + 转场) -- 一个片段配个旁白/配音 → `audio-mix`(多轨混音,延时/音量可控) -- 稍微剪一下(入点/出点/倍速)→ `clip-trim`(`--pre-buffer` 避免切 MP3 吞首字) -- 按时间轴切素材+混音 → `timeline-compose`(`audio_mode=concat` 出连续轨) -- 单个 Scene 合成(片段+旁白+对白)→ `scene-compose` -- 无音频片段补静音轨 → `add-silent-audio` -- 片尾制作(形象图+黑边+烧字幕)→ `make-outro` - -不适用: - -- 纸拼贴 B-roll → `collage-broll` -- Manim 技术演示动画 → `manim-explainer` -- 平面设计 → `design-full` -- 基于已有素材的深度高光剪辑(去口气词/智能剪重点)→ main 的 `video-edit` / `talking-head-cut`(本技能模式 B 只做几何级修整:切段/拼接/混音/烧字幕/补轨,不做语义级剪辑) -- 视频下载与爆款分析 → main 的 `viral-chaser` - ---- - -## 工作区目录约定 - -调用方传入了现成项目目录时(如平台专家包委托制作,传入 `/outputs//`,brief 已在其中)直接沿用;否则在 `output_videos/` 下建项目文件夹 `/`: - -``` -/ # 即 /outputs// 或 output_videos// -├── brief.md # Stage 0/1 产出:意图路由 + 概念选项 + 用户选定 -├── reference-driven/ # Stage 1(可选,仅当 main 喂了 viral-chaser 报告) -│ ├── viral-chaser-report.md # main 喂入的追爆报告原档(本技能不自己跑 viral-chaser) -│ ├── concepts.md # 据报告出的 2–3 差异化概念 + 成本 + 备选路径 -│ └: 无下载产物、无 transcript、无关键帧——那些归 viral-chaser -├── script/ -│ ├── intent.json # Stage 0 -│ ├── story.md # Stage 2 -│ ├── script.md # Stage 3(含 enhancement_cues 六型 + delivery_cues) -│ ├── self-eval.json # Stage 3 自评 -│ ├── decisions.json # 决策审计链(跨阶段累积) -├── storyboard/ -│ ├── storyboard.json # Stage 4 镜头表 -│ ├── shot_decompose.json # Stage 5 每镜首尾帧+运动+variation_type -├── characters/ -│ ├── registry.json # Stage 6 static/dynamic features -│ ├── /front.png # 三视图(调 siliconflow-img-gen 生成) -│ ├── /side.png -│ └── /back.png -├── gates/ -│ ├── gate-a.md # GATE A 文本闸门评审产物 -│ ├── gate-b.md # GATE B 素材闸门评审产物 -├── slots/ -│ ├── slot-plan.json # Stage 7 -│ ├── asset-resolve.json # Stage 8(含 rejected_picks) -│ ├── slideshow-risk.json # Stage 9 六维打分 -│ ├── delivery-promise.json # Stage 9 承诺锁定 -├── render/ -│ ├── shot-NN/ # Stage 10 每镜渲染产物 -│ │ ├── first-frame.png # 首帧静照(生成或素材裁切) -│ │ ├── last-frame.png # 尾帧静照 -│ │ ├── gen-run-v01.mp4 # aigc-video-gen i2v 产物 -│ │ └ettings.log -│ │ └ulti-best.mp4 # 多候选择优胜出(多候选时) -├── audio/ -│ ├── narration.mp3 # awk-tts 旁白 -│ ├── bgm.mp3 # BGM(bgm-library 免版税曲库优先 / pexels/pixabay / aigc-video-gen music 生成) -│ ├── subtitles.srt # 字幕 -├── artifacts/ # Stage 12 按镜顺序的最终段 -│ ├── 01_*.mp4 -│ └ NN_*.mp4 -├── video.mp4 # Stage 12 拼接成片 -├── review/ # Stage 13 公共 video-review 产物 -│ ├── verdict.json -│ ├── frames/ -│ ├── motion-audit.json # CP 侧 motion_led 抽查 -├── cover.jpg # Stage 14 封面 -└── final-deliver.md # Stage 14 交付清单 -``` - ---- - -## 阶段链(15 段,两闸门) - -每段子命令是 `scripts/` 下一个独立 .py,agent 按本 SKILL.md 工作流逐个调。**产物文件存在性即 checkpoint**——每个子命令先查产物文件是否存在,存在则 load 不重生成(允许用户手改 JSON 后续跑)。 - -``` -Stage 0 intent-router 意图路由 → 三档脚本模板(故事讲述型/纯画面动效型/蒙太奇剪接型) - · 故事讲述型(narrative)——重情节、有人物弧光、含旁白;默认 3–5 镜/场 - · 纯画面动效型(motion)——重节奏感/视觉冲击/少对白;默认 5–8 镜快切 - · 蒙太奇剪接型(montage)——重氛围/抽象/纯视觉;默认 4–7 镜无叙事 -Stage 1 reference-concepts 若 main 喂了 viral-chaser 报告 → 吃报告出 2–3 差异化概念;无报告跳过 -Stage 2 story-develop idea → 故事(含受众/类型显式复述,100–200 词梗概,人物,分场) -Stage 3 script-write 故事 → 分场剧本(同时间同地点分一场;可拍化描述;enhancer 润色) -Stage 3b script-self-eval 脚本自评 N 维打分,任一维 <3 必返工 -Stage 4 storyboard-build 剧本 → 镜头表(每镜叙事目的/机位复用/位置朝向/不写不可见) -Stage 5 shot-decompose 每镜拆首帧静照/尾帧静照/运动描述(variation_type 三档) -Stage 6 character-register 角色三视图 front/side/back + static/dynamic features 拆分 - ────── GATE A:文本闸门(脚本+分镜+机位+角色全齐,停,发用户审)────── -Stage 7 slot-plan 素材 slot 规划(template + hero slot + tone→slot 数) -Stage 8 asset-resolve 按 slot 拉素材(Fast path:多源并发搜 + 缩略图人核 + rejected_picks 落盘) -Stage 9a slideshow-risk 六维幻灯风险打分(pre-compose 闸门,≥4.0 fail 不许进 compose) -Stage 9b delivery-promise-lock 交付承诺八类锁定 + motion_ratio 预估 - ────── GATE B:素材闸门(素材齐+计划过审,停,发用户看 contact sheet)────── -Stage 10 render-shot 按 slot 渲染(AIGC 走 aigc-video-gen i2v 首尾帧插值;静图走 siliconflow-img-gen) -Stage 11 mix-audio 配音配乐四场景分流(A 人物对话声画同出 / B 旁白一次性 TTS 带字级时间戳 + 对齐 / C BGM 成片后统一生成(优先 bgm-library 免版税曲库,pexels/pixabay 并列;定制风格用 aigc-video-gen music)/ D 用户口播录音 → ASR 时间戳 → 按时间戳补素材) -Stage 12 assemble 按序拼接成片(原子工具箱:clip-trim 切段 / audio-mix 混音 / timeline-compose 时间轴合成 / assemble 拼接,agent 按 §Stage 12 工具箱场景化组合,不写死 Workflow) -Stage 13a video-review 公共 video-review 技术自检(强制闸门) -Stage 13b motion-audit CP 侧 motion_led 抽查(兑付 delivery-promise) -Stage 14a make-cover 封面(siliconflow-img-gen,必含标题文字) -Stage 14b 交付 向用户呈交成片+封面+关键参数 -``` - -> Stage 0–6 全是**文本产物**,付费生成前必停——GATE A 落在这条边界上。GATE B 落在素材就绪、pre-compose 闸门通过后,确认渲染前最终计划。 - ---- - -## 子命令调用清单 - -| 子命令 | 入 | 出 | 用途 | -|--------|----|----|------| -| `intent-router` | brief.md(主题/关键词,或 viral-chaser 报告) | `script/intent.json`(档位+主题) | Stage 0 | -| `reference-concepts` | viral-chaser 报告(main 喂入,可选) | `reference-driven/concepts.md` | Stage 1:只吃报告出概念,不做下载/转写/抽帧;无报告跳过 | -| `story-develop` | intent.json | `script/story.md` | Stage 2 | -| `script-write` | story.md | `script/script.md`(含 enhancement_cues + delivery_cues) | Stage 3 | -| `script-self-eval` | script.md | `script/self-eval.json`(N 维分,任一维 <3 必返工) | Stage 3b | -| `storyboard-build` | script.md | `storyboard/storyboard.json` | Stage 4 | -| `shot-decompose` | storyboard.json | `storyboard/shot_decompose.json`(每镜首尾帧+运动+variation_type) | Stage 5 | -| `character-register` | storyboard.json + 人物描述 | `characters/registry.json` + 三视图 PNG | Stage 6(三视图调 siliconflow-img-gen) | -| `slot-plan` | storyboard.json + tone | `slots/slot-plan.json` | Stage 7 | -| `asset-resolve` | slot-plan.json | `slots/asset-resolve.json`(含 rejected_picks)+ 素材落 `raw_materials/` | Stage 8(调 pexels-footage / pixabay-footage / aigc-video-gen) | -| `slideshow-risk` | storyboard.json + slot-plan.json + asset-resolve.json | `slots/slideshow-risk.json`(六维分) | Stage 9a | -| `delivery-promise-lock` | storyboard.json + brief.md | `slots/delivery-promise.json`(八类锁) | Stage 9b | -| `render-shot` | shot_decompose.json + characters/ + slot-picks | `render/shot-NN/` 下产物 | Stage 10(调 aigc-video-gen i2v / siliconflow-img-gen) | -| `mix-audio` | script.md(delivery_cues) | `audio/` 目录 + `subtitles.srt` 模板 | Stage 11(四场景分流:A 声画同出 / B 旁白一次性 TTS+ASR 对齐 / C BGM 成片后生成(优先 bgm-library 免版税曲库 / pexels/pixabay 并列;定制风格用 aigc-video-gen music)/ D 用户口播录音 → ASR 时间戳 → 按时间戳补素材) | -| `narration-align` | audio/narration.mp3 + audio/narration.subtitle.json | `audio/narration-segments.json`(统一 segments 格式) | Stage 11b(优先复用 awk-tts --enable-subtitle 落盘的 TTS 原生字级时间戳,缺失时回退火山 ASR 极速版,凭据复用 viral-chaser 同池 `VOLC_ASR_*`) | -| `assemble` | render/ 顺序 | `video.mp4` | Stage 12(按序拼接 + 可选转场 + 可选分辨率归一化 + 自动补静音/统一音频格式) | -| `add-silent-audio` | 无音频视频片段 | 含静音音轨的视频 | Stage 12 原子工具:concat 前补齐音轨,保持连续 | -| `clip-trim` | 素材路径 + 入点/出点/倍速 | 切好的片段 | Stage 12 原子工具:精确切素材,视频和音频分别处理 | -| `audio-mix` | 多条音轨 + 各自延时/音量 | 混合音频 | Stage 12 原子工具:多轨混音 | -| `timeline-compose` | timeline.json(每段素材/入点/出点/倍速/音轨及延时) | 合成片段 | Stage 12 原子工具:按时间轴调 clip-trim + audio-mix 合成 | -| `motion-audit` | video.mp4 + delivery-promise.json | `review/motion-audit.json`(motion_led 抽查) | Stage 13b(补公共 video-review) | -| `make-cover` | brief.md(标题)+ storyboard 关键帧 | `cover.jpg` | Stage 14a(调 siliconflow-img-gen) | - -> wrapper `video-producer.sh` 内部 `exec python3 "$SCRIPT_DIR/scripts/<子命令>.py" "$@"`——子命令名即脚本名,零路径拼接。 - ---- - -## 强制闸门与护栏 - -### GATE A(Stage 6 后):文本闸门 - -文本产物全齐(脚本+分镜+机位+角色),**停下发用户审**: - -- 呈交摘要:档位、场次数、镜数、角色数、关键决策(路径/模型/风格选择的备选+置信度+理由) -- **结束本轮回复**,不许在同条回复里进 Stage 7 -- 批准是**逐闸门的**——早先的一句"你继续"不覆盖本闸门 -- 用户要改哪段就重跑对应子命令(产物文件存在性即 checkpoint,不会重生成未改的) - -### GATE B(Stage 9 后):素材闸门 - -素材齐 + 计划过 slideshow_risk + delivery_promise 锁,**停下发用户看 contact sheet**: - -- 呈交:slot 总数、素材就绪率、slideshow_risk 六维分与 verdict、delivery_promise 八类与 motion_ratio 预估、素材 contact sheet -- 同 GATE A 收尾纪律 - -### 返工与耗时上限 - -- 每阶段最多返工 **3 次** -- 全片最多 **3 次** send-back -- 每阶段 wall-time 默认上限 **20 分钟**——卡住要报,不要反复撞 - -### 不许声称没做过的事 - -没有 tool result 或产物文件证明,不许声称已渲染/已生成/已改动。 - -### 模糊意图不算确认 - -- 用户说"做个短片""帮我策划"**不算确认**,必须先问清楚走哪条 workflow(故事讲述型/纯画面动效型/蒙太奇剪接型)、时长、受众 -- 起草/讨论脚本属对话协助,**不许调 render 工具** -- 默认**小规模**:1 场 3–5 镜,不许把模糊想法擅自扩成多场多镜;用户要扩才扩 - -### 决策审计链 - -每个选择(路径/模型/风格/音色/任何 fallback)记 `备选 + 置信度 + 理由`,跨阶段累积进 `script/decisions.json`。 - ---- - -## 依赖 - -| 依赖 | 来源 | 用在哪 | -|------|------|------| -| python3 / ffmpeg / ffprobe | 系统 | 各阶段脚本 | -| 公共 `aigc-video-gen` | skills/ | Stage 8/10 视频片段生成(百炼/火山声画同出,i2v 首尾帧插值) | -| 公共 `siliconflow-img-gen` | skills/ | Stage 6 角色三视图 / Stage 10 静帧 / Stage 14 封面 | -| 公共 `awk-tts` | skills/ | Stage 11B 旁白一次性 TTS(OpenClaw 内置 TTS 优先 → awk-tts fallback;加 `--enable-subtitle` 让火山单向流式 HTTP 原生返回字级时间戳,落 `narration.subtitle.json`) | -| 火山 ASR 凭据 `VOLC_ASR_*` | 实例 env | Stage 11b narration-align 回退路径 + Stage 11D 用户口播录音转写拿时间戳(复用 viral-chaser 同池:旧控制台双头 `VOLC_ASR_APP_ID`+`VOLC_ASR_ACCESS_KEY`,或新控制台单头 `VOLC_ASR_APP_KEY`) | -| 公共 `pexels-footage` / `pixabay-footage` | skills/ | Stage 8 Stock Footage 素材补充 / Stage 11C BGM 搜 | -| 公共 `bgm-library` | skills/ | Stage 11C BGM 搜(ccMixter 免版税 + 自动 TASL 署名,免 key,商用安全;与 pexels/pixabay 并列,优先用) | -| 公共 `video-review` | skills/ | Stage 13a 成片技术自检闸门 | -| `requests` | 仓根 requirements.txt | 各脚本 HTTP 调用 | - ---- - -## 子命令清单(wrapper 视角) - -| 子命令 | 用途 | 退出码 | -|--------|------|--------| -| `video-producer intent-router` | 意图路由三档 | 0 成功 / 1 参数错 / 2 env 未配 | -| `video-producer reference-concepts` | 吃 viral-chaser 报告出概念 | 0 成功 / 1 参数错 | -| `video-producer story-develop` | idea → 故事 | 0 成功 / 1 参数错 | -| `video-producer script-write` | 故事 → 分场剧本 | 0 成功 / 1 参数错 | -| `video-producer script-self-eval` | 脚本自评 N 维 | 0 成功 / 1 参数错 | -| `video-producer storyboard-build` | 剧本 → 镜头表 | 0 成功 / 1 参数错 | -| `video-producer shot-decompose` | 每镜拆首尾帧+运动 | 0 成功 / 1 参数错 | -| `video-producer character-register` | 角色三视图 + features | 0 成功 / 1 参数错 | -| `video-producer slot-plan` | 素材 slot 规划 | 0 成功 / 1 参数错 | -| `video-producer asset-resolve` | 按 slot 拉素材 | 0 成功 / 1 参数错 / 2 env 未配 | -| `video-producer slideshow-risk` | 六维幻灯风险打分 | 0 成功 / 1 参数错 | -| `video-producer delivery-promise-lock` | 八类承诺锁定 | 0 成功 / 1 参数错 | -| `video-producer render-shot` | 按 slot 渲染 | 0 成功 / 1 参数错 / 2 env 未配 | -| `video-producer mix-audio` | 三场景分流占位 | 0 成功 / 1 参数错 | -| `video-producer narration-align` | 旁白 ASR 对齐时间戳 | 0 成功 / 1 参数错 / 2 env 未配 | -| `video-producer assemble` | 按序拼接成片 | 0 成功 / 1 参数错 | -| `video-producer add-silent-audio` | 给无音频视频补静音轨 | 0 成功 / 1 参数错 | -| `video-producer scene-compose` | 单 Scene 分段合成 | 0 成功 / 1 参数错 | -| `video-producer make-outro` | 片尾制作 | 0 成功 / 1 参数错 | -| `video-producer clip-trim` | 切素材段 | 0 成功 / 1 参数错 | -| `video-producer audio-mix` | 多轨混音 | 0 成功 / 1 参数错 | -| `video-producer timeline-compose` | 时间轴合成 | 0 成功 / 1 参数错 | -| `video-producer motion-audit` | motion_led 抽查 | 0 成功 / 1 参数错 | -| `video-producer make-cover` | 封面(必含标题) | 0 成功 / 1 参数错 / 2 env 未配 | - ---- - -## Stage 12 工具箱(场景化组合,不写死 Workflow) - -Stage 12 段**不规定固定 Workflow**——下面四个原子工具 agent 按实际场景灵活组合。 - -### 原子工具清单 - -| 工具 | 干什么 | 关键参数 | -|------|--------|---------| -| `clip-trim` | 精确切素材段(入点/出点/倍速/前置缓冲,视频和音频分别处理) | `--input/--output/--start/--end/--speed/--sync-audio/--pre-buffer` | -| `audio-mix` | 多轨混音(每轨独立延时和音量) | `--track(可重复)/--delay/--volume/--output/--duration` | -| `timeline-compose` | 按时间轴 JSON 调 clip-trim + audio-mix 合成片段 | ` --timeline timeline.json [--transition ...]` | -| `assemble` | 按序拼接已就绪的段 + 可选转场 + 自动分辨率归一化 + 自动统一音频格式 | ` [--transition hard/fade/dissolve/xfade] [--width 1080] [--fps 30] [--audio-format 24000/mono] [--low-memory] [--preview-duration 30]` | -| `add-silent-audio` | 给无音频视频片段补静音音轨(concat 前置,assemble 内部也自动调) | `--input/--output/--duration/--sample-rate 24000/--channels mono` | -| `scene-compose` | 单 Scene 分段合成(片段+旁白+对白 → 一个 Scene 片段) | ` --scene scene.json [--output scene-01.mp4]` | -| `make-outro` | 片尾制作(形象图+黑边+烧字幕+静音轨 → 标准比例片尾) | ` --image <形象图> --slogan <文本> [--color color.json] [--duration 5] [--width 1080] [--fps 30]` | - -### 场景化组合示例(非强制,agent 按实际判断) - -**场景 A:无旁白直拼** -段就绪、无需切素材、无需混音——`assemble --transition fade` 一把过。 - -**场景 B:有旁白走时间轴** -旁白一次性 TTS 生成 + narration-align 拿字级时间戳后,按时间戳把各段素材对齐旁白: -1. agent 据 narration-segments.json 各段 start/end 冡素材入点/出点,写 timeline.json -2. `timeline-compose --timeline timeline.json` → 调 clip-trim 切段 + audio-mix 把旁白按延时叠到各段 -3. 全段 BGM 走 timeline.json 的 `audio_globals` 字段混入 - -**场景 C:分段先合再合(scene-compose 两阶段)** -长片或某些段需独立预合(如先合 shot-01~03 为一场 Scene,再合 shot-04~06 为另一场,最后两场 concat): -1. 写 `scene-01.json`(clips 片段列表 + narration 旁白 + dialogue 对白),跑 `scene-compose --scene scene-01.json --output scene-01.mp4` -2. 同样出 `scene-02.mp4` -3. 把 scene-01.mp4 / scene-02.mp4 当段素材,再跑 `assemble --source-dir scenes --transition fade` 合片 -scene-compose 内部调 clip-trim 切段 + audio-mix 混旁白/对白 + assemble 拼段,一步出单 Scene 完整片段。 - -**场景 D:素材尺寸不一** -AIGC 720x1280、录屏 1080x2384、片尾 784x1176 混拼——`assemble` 传 `--width 1080 --fps 30` 归一化(scale + pad 16:9 + sar + fps 统一)后再 concat。 - -**场景 E:精确调速某段** -某段需 2x 快放——`clip-trim --input <段> --output <快放段> --speed 2 --sync-audio` 切好后,把快放段当段素材再拼。 - -**场景 F:低内存机器** -机器内存吃紧(< 4G 或容器限额)——`assemble --transition fade --low-memory`,preset 切 ultrafast、crf 放宽到 28,避免 x264 缓冲爆内存。打印里会标注 `低内存模式:preset=ultrafast, crf=28`。 - -**场景 G:先试听再合成** -长片合成前想先听前 30 秒验证旁白/BGM 平衡——`assemble --preview-duration 30`,成片照常落,额外产 `video-preview.mp4`(前 30 秒,`-c copy` 秒切)。试听满意后再决定是否重混。 - -**场景 H:AIGC 无音频段混拼 + 旁白切段吞首字** -AIGC i2v 产物常无音频流,与有音频段直拼会断续;旁白用 `clip-trim --ss` 切 MP3 会吞第一个字。`assemble` 现在自动统一音频格式(默认 24000/mono,无音频段补静音,规格不符段重采样)+ 不传 `--width/--fps` 时自动探测各段分辨率/帧率、不一时统一到最低公共规格。旁白切段走 `clip-trim --pre-buffer 0.5`,实际入点提前 0.5s 保留段头音频,逻辑入点不变。 - -agent 据剧本实际选场景组合,可混合多场景(如 B+E:旁白时间轴 + 某段快放)。脚本不预设顺序。 - ---- - -## 禁止事项(强制) - -- **禁止跳过 GATE A/B 交付**:两闸门是工作流的一部分,呈交摘要后必须结束本轮回复等用户批 -- **禁止声称没做过的事**:没有 tool result 或产物文件证明,不许声称已渲染/已生成/已改动 -- **禁止把模糊想法擅自扩成多场多镜**:默认 1 场 3–5 镜,用户要扩才扩 -- **禁止跳过 video-review 交付**:Stage 13a 强制闸门,verdict=pass 才进 Stage 14 -- **禁止直接写 ffmpeg 命令**:所有 ffmpeg 调用走本技能子命令脚本或公共 video-edit 子命令 -- **禁止自己做视频下载/转写/抽帧**:那是 viral-chaser 的活,本技能只吃 main 喂入的报告 -- **禁止引入 CLIP / torch 系本地模型**:素材匹配走 Fast path 人核缩略图 -- **禁止扩充图库源**:保 Pexels + Pixabay 两源 -- **禁止批量生成**:逐条精做,不批量撞运气 diff --git a/crews/content-producer/skills/video-producer/scripts/clip-trim.py b/crews/content-producer/skills/video-producer/scripts/clip-trim.py deleted file mode 100644 index 8b6d2f05..00000000 --- a/crews/content-producer/skills/video-producer/scripts/clip-trim.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -"""clip-trim — 精确切素材:指定入点/出点/倍速,支持视频和音频分别处理。 - -原子工具,不写死 Workflow。agent 按 SKILL.md 场景化组合调用。 - -Usage: - # 切视频段(0.5s 入,2.3s 出,1.5x 倍速) - python3 scripts/clip-trim.py --input shot.mp4 --output clip.mp4 --start 0.5 --end 2.3 --speed 1.5 - - # 只切音频段(30s 入,35s 出,原速) - python3 scripts/clip-trim.py --input narration.mp3 --output clip.mp3 --start 30 --end 35 - - # 视频倍速时同步音频倍速(setpts + atempo) - python3 scripts/clip-trim.py --input shot.mp4 --output clip.mp4 --start 1 --end 3 --speed 2 --sync-audio - -参数说明: - --input 输入素材路径(视频或音频文件) - --output 输出路径 - --start 入点(秒,默认 0) - --end 出点(秒,默认 = 输入全长) - --speed 倍速(默认 1.0;2.0 = 2x,0.5 = 0.5x) - --sync-audio 视频倍速时同步音频倍速(用 atempo filter;超出 0.5-2.0 范围链式串联) - -视频倍速原理:setpts=PTS/speed 改时间戳;音频 atempo=speed 改播放速率。 -倍速后时长 = (end - start) / speed。 -""" - -import argparse -import json -import subprocess -import sys -from pathlib import Path - - -def die(msg: str) -> None: - print(f"[error] {msg}", file=sys.stderr) - sys.exit(1) - - -def run(cmd: list[str]) -> subprocess.CompletedProcess: - print(f"[cmd] {cmd[0]} ... ({len(cmd)} args)") - p = subprocess.run(cmd, capture_output=True, text=True) - if p.returncode != 0: - die(f"命令失败 (rc={p.returncode}): {p.stderr[:500] or p.stdout[:500]}") - return p - - -def probe_duration(path: Path) -> float: - p = subprocess.run( - ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)], - capture_output=True, text=True, - ) - if p.returncode != 0: - return 0.0 - try: - return float(json.loads(p.stdout).get("format", {}).get("duration", 0) or 0) - except Exception: - return 0.0 - - -def is_video(path: Path) -> bool: - """用 ffprobe 看是否有视频流。""" - p = subprocess.run( - ["ffprobe", "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)], - capture_output=True, text=True, - ) - return p.stdout.strip() == "video" - - -def atempo_chain(speed: float) -> str: - """atempo filter 链。atempo 单级范围 [0.5, 2.0],超出则链式串联。 - 例:4x → atempo=2.0,atempo=2.0;0.25x → atempo=0.5,atempo=0.5。""" - if 0.5 <= speed <= 2.0: - return f"atempo={speed}" - parts = [] - remaining = speed - while remaining > 2.0: - parts.append("atempo=2.0") - remaining /= 2.0 - while remaining < 0.5: - parts.append("atempo=0.5") - remaining /= 0.5 - parts.append(f"atempo={remaining}") - return ",".join(parts) - - -def main() -> None: - parser = argparse.ArgumentParser(description="clip-trim 精确切素材") - parser.add_argument("--input", required=True, help="输入素材路径(视频或音频)") - parser.add_argument("--output", required=True, help="输出路径") - parser.add_argument("--start", type=float, default=0.0, help="入点(秒,默认 0)") - parser.add_argument("--end", type=float, default=None, help="出点(秒,默认=输入全长)") - parser.add_argument("--speed", type=float, default=1.0, help="倍速(默认 1.0)") - parser.add_argument("--sync-audio", action="store_true", help="视频倍速时同步音频倍速") - parser.add_argument("--pre-buffer", type=float, default=0.0, - help="切段前置缓冲(秒,默认 0):实际入点提前该值,避免 -ss 切 MP3 吞首字;逻辑入点仍是原 start") - args = parser.parse_args() - - src = Path(args.input).resolve() - dst = Path(args.output).resolve() - if not src.is_file(): - die(f"输入不存在: {src}") - - src_dur = probe_duration(src) - end = args.end if args.end is not None else src_dur - if end <= args.start: - die(f"出点({end})必须大于入点({args.start})") - if args.speed <= 0: - die(f"倍速必须 > 0,收到 {args.speed}") - if args.pre_buffer < 0: - die(f"--pre-buffer 必须 >= 0,收到 {args.pre_buffer}") - - # pre-buffer:实际入点提前 N 秒(不越过 0),保留段头音频避免 -ss 吞首字 - real_start = max(0.0, args.start - args.pre_buffer) - trim_dur = end - real_start - out_dur = trim_dur / args.speed - - # -ss/-t 放 -i 后(output seek,精确切);倍速时 -t 限的是输出时长(out_dur) - cmd = ["ffmpeg", "-y", "-i", str(src), "-ss", str(real_start), "-t", str(out_dur)] - - has_video = is_video(src) - vf_parts = [] - af_parts = [] - - if has_video and args.speed != 1.0: - vf_parts.append(f"setpts=PTS/{args.speed}") - if args.sync_audio: - af_parts.append(atempo_chain(args.speed)) - - if vf_parts: - cmd.extend(["-vf", ",".join(vf_parts)]) - if af_parts: - cmd.extend(["-af", ",".join(af_parts)]) - - # 视频流编码设置(音频文件无视频流时跳过) - if has_video: - cmd.extend(["-c:v", "libx264", "-preset", "fast", "-crf", "23"]) - cmd.extend(["-c:a", "aac", "-b:a", "192k"]) - - cmd.append(str(dst)) - run(cmd) - - actual_dur = probe_duration(dst) - print(f"[done] {src.name} → {dst.name}") - print(f" - 入点 {real_start}s 出点 {end}s,倍速 {args.speed}x") - print(f" - 期望时长 {out_dur:.3f}s,实际 {actual_dur:.3f}s") - if args.pre_buffer > 0: - print(f" - 前置缓冲 {args.pre_buffer}s(逻辑入点 {args.start}s)") - if has_video and args.sync_audio: - print(f" - 视音频同步倍速") - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/video-producer/scripts/reference-concepts.py b/crews/content-producer/skills/video-producer/scripts/reference-concepts.py deleted file mode 100644 index 1e97f0eb..00000000 --- a/crews/content-producer/skills/video-producer/scripts/reference-concepts.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Stage 1 — reference-concepts:吃 main 喂入的 viral-chaser 报告出 2–3 差异化概念。 - -本技能不做视频下载/转写/抽帧——那是 viral-chaser 的活。只接报告原档当输入。 - -Usage: - python3 scripts/reference-concepts.py --report-file path - -入:project_dir(output_videos// 或平台运营目录 /outputs//)+ viral-chaser 报告路径 -出:project_dir/reference-driven/concepts.md(2–3 差异化概念 + 成本 + 备选路径) - -无报告则跳过本阶段,agent 直入 Stage 2 story-develop(本脚本不报错退出)。 -""" - -import argparse -import sys -from pathlib import Path - -def main() -> None: - parser = argparse.ArgumentParser(description="Stage 1 reference-concepts") - parser.add_argument("project_dir", help="项目目录(output_videos// 或平台运营目录 /outputs//)") - parser.add_argument("--report-file", default=None, help="main 喂入的 viral-chaser 报告路径") - args = parser.parse_args() - - project = Path(args.project_dir).resolve() - out_dir = project / "reference-driven" - out_dir.mkdir(parents=True, exist_ok=True) - concepts_path = out_dir / "concepts.md" - - # checkpoint - if concepts_path.is_file(): - print(f"[checkpoint] concepts.md 已存在,沿用:{concepts_path}") - return - - if not args.report_file: - print("[skip] 无 viral-chaser 报告输入,跳过 Stage 1,直入 Stage 2 story-develop") - return - - report = Path(args.report_file) - if not report.is_file(): - print(f"[warn] 报告文件不存在: {args.report_file},跳过 Stage 1") - return - - # 复制报告原档到工作区(不做任何下载/转写/抽帧,只存档供后续阶段参考) - import shutil - archived = out_dir / "viral-chaser-report.md" - if not archived.is_file(): - shutil.copy2(report, archived) - - # 提示 agent 据报告出 2–3 差异化概念写入 concepts.md - stub = f"""# 据参考片出的差异化概念(Stage 1) - -## 参考片来源 - -{archived.name}(main agent 喂入的 viral-chaser 追爆报告原档,未做下载/转写/抽帧)。 - -## 概念候选(agent 据报告填) - -> agent 读 archived 报告,出 2–3 个**差异化**概念(不抄原片,做差异化),每个概念含: -> - 名称与一句话定位 -> - 与参考片的差异化点(节奏/钩子/结构/调性任一的差异化) -> - 预算估算(USD) -> - 备选路径(如该概念走不通的 fallback) - -### 概念 1 -(agent 填) - -### 概念 2 -(agent 填) - -### 概念 3(可选) -(agent 填) - -## 用户选定 - -> 呈交用户选定一个概念,写入 brief.md。未选定前不进 Stage 2。 -""" - concepts_path.write_text(stub, encoding="utf-8") - print(f"[done] 报告已存档:{archived}") - print(f"[stub] concepts.md 模板已落:{concepts_path}") - print(f"[next] agent 据报告填概念 → 呈交用户选定 → 跑 story-develop(Stage 2)") - - -if __name__ == "__main__": - main() diff --git a/crews/content-producer/skills/video-producer/video-producer.sh b/crews/content-producer/skills/video-producer/video-producer.sh deleted file mode 100755 index 8293626f..00000000 --- a/crews/content-producer/skills/video-producer/video-producer.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# video-producer.sh — video-producer 顶层 wrapper(薄转发,子命令范式) -# 让 agent 用 `video-producer <子命令> [参数...]` 走 PATH,零路径拼接。 -# 子命令即 scripts/ 下同名 .py,wrapper 转发到对应脚本,不改语义。 -# 子命令清单见 SKILL.md;每阶段是 scripts/ 下一个独立脚本, -# agent 按 SKILL.md 工作流逐个调,产物文件存在性即 checkpoint(不引状态机)。 -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:?用法: video-producer <子命令> [参数...]}" -shift -case "$SUBCMD" in - -h|--help|help) - cat <<'HELP' -video-producer — 端到端视频制作(wrapper) - -用法: - video-producer <子命令> [参数...] 跑对应阶段的原子脚本 - video-producer help 列可用子命令 - -子命令(按工作流阶段序): - intent-router Stage 0 意图路由 → 三档脚本模板(故事讲述型/纯画面动效型/蒙太奇剪接型) - reference-concepts Stage 1 吃 viral-chaser 报告出 2–3 差异化概念(可选,无报告跳过) - story-develop Stage 2 idea → 故事(分场) - script-write Stage 3 故事 → 分场剧本(含 enhancement_cues + delivery_cues) - script-self-eval Stage 3 脚本自评 N 维打分 - storyboard-build Stage 4 剧本 → 镜头表 - shot-decompose Stage 5 每镜拆首尾帧 + 运动描述 + variation_type - character-register Stage 6 角色三视图 + static/dynamic features 拆分 - slot-plan Stage 7 素材 slot 规划 - asset-resolve Stage 8 按 slot 拉素材(Fast path 人核缩略图) - slideshow-risk Stage 9 六维幻灯风险打分(pre-compose 闸门) - delivery-promise-lock Stage 9 交付承诺八类锁定 - render-shot Stage 10 按 slot 渲染(AIGC i2v / 静图) - mix-audio Stage 11 旁白(awk-tts)+ BGM + 字幕 - assemble Stage 12 按镜顺序拼接成片 + 转场 - add-silent-audio 给无音频的视频片段补静音音轨(concat 前置) - scene-compose Stage 12 单 Scene 分段合成(片段+旁白+对白 → 一个 Scene 片段) - make-outro Stage 12 片尾制作(形象图+黑边+烧字幕+静音轨 → 标准比例片尾) - motion-audit Stage 13 motion_led 抽查(补公共 video-review) - make-cover Stage 14 封面(siliconflow-img-gen,必含标题文字) - -闸门不是子命令——GATE A(Stage 6 后文本闸门)与 GATE B(Stage 9 后素材闸门)由 agent -按 SKILL.md 工作流执行:呈交摘要 → 结束本轮回复 → 等用户逐闸门批准。 - -产物文件存在性即 checkpoint:每个子命令先查产物文件是否存在,存在则 load 不重生成。 -HELP - ;; - *) - SCRIPT="$SCRIPT_DIR/scripts/${SUBCMD}.py" - if [ ! -f "$SCRIPT" ]; then - echo "未知子命令: $SUBCMD" >&2 - echo "用 video-producer help 查可用子命令" >&2 - exit 1 - fi - exec python3 "$SCRIPT" "$@" - ;; -esac diff --git a/crews/main/AGENTS.md b/crews/main/AGENTS.md index bceffd0c..33c28286 100644 --- a/crews/main/AGENTS.md +++ b/crews/main/AGENTS.md @@ -23,7 +23,7 @@ - 专家包按任务路由,互不越界:推特/小红书评论区获客 / 截流等 BD 场景走 `expert-bd`,不走平台运营包。 - 商业模式打磨(接触投资人前的前置环节)不属于任何专家包:结合 `business_knowledge.md` 直接与用户对话完成(多路径权衡用 `council`),结论落 `MEMORY.md`。 -- **零星工作兜底**:未被专家包覆盖的任务,可直接调用手头的工具完成(skill 清单会话时会自动加载,此处不列出);更适合其他 crew 承担的工作,以 spawn subagent 的方式委托(如从零生产完整视频交 content-producer,技术问题、系统排障与环境配置交 IT engineer)。找不到匹配的专家包或工具时,先询问用户或保守处理,不猜测平台规则与 DNA。 +- **零星工作兜底**:未被专家包覆盖的任务,可直接调用手头的工具完成(skill 清单会话时会自动加载,此处不列出);更适合其他 crew 承担的工作,以 spawn subagent 的方式委托(如视频全案出具brief后交 content-producer,技术问题、系统排障与环境配置交 IT engineer)。找不到匹配的专家包或工具时,先询问用户或保守处理,不猜测平台规则与 DNA。 - crew 生命周期管理(启用/停用/调整其他 crew)是你的固有职责,不经专家包路由,见下文「crew 管理」段。 ## 数据存储 @@ -77,12 +77,10 @@ index.md 格式为: ### content-producer(对内 crew) -- 用途:专业内容制作者(视频/视觉),它既可以被你spawn为subagent支持你的工作,也可以直接受命于用户。 -- 启用流程: - 1. **先判断** `openclaw.json` 的 `channels` 段是否已配置飞书 channel 或企业微信 channel。 - 2. **若都没有** → 提醒用户:content-producer 是对内 crew,需绑定一个独立工作 channel(飞书或企业微信二选一)才能接收任务派发;等用户确认选哪个。 - 3. 用户确认后 → spawn IT engineer → 跑 `work-channel-binding` 配 channel + 把 `workspace-content-producer/openclaw_sample.json` 并入 `openclaw.json`(加入 `agents.list` + 绑该工作 channel)。 -- 若已有飞书或企业微信 channel → 跳过提醒,直接 spawn IT engineer 合入 openclaw_sample.json。 +- 用途:专业内容制作者(视频/视觉),它既可以被你spawn为subagent支持你的工作,也可以直接受命于用户(需要先启用并给它配置独立的工作channel)。 +它有两个专家包:`expert-video`(视频制作)与 `expert-design`(平面设计)。涉及到视频全案制作或者设计全案制作时应该将任务委托给它,这种情况下你们的分工约定如下: +>1. **甲乙方分工**:你(甲方)负责选题策划、按 DNA 出 `brief.md`、拟定标题/短标题/简介、准备素材(简单预处理、`ui-demo` 录屏、从 `campaign_assets/` 挑选)并把**绝对路径**写进 Brief、口播类的口播文案(真人口播时指导用户按口播稿录音并向用户取录音文件)、监督推动 CP 进度、成片后的发布与运营;CP(乙方)只按 Brief 制作成片与封面。 +>2. **交接物**:你给「Brief + 已有素材绝对路径 + 口播文案/录音(如有)」,CP 回「成片 + 封面 + 交付说明」的绝对路径,你取回作品目录后再发布。 ### 通用约束 diff --git a/crews/main/skills/_shared/check-session.ts b/crews/main/skills/_shared/check-session.ts index 04c4d366..e16efd53 100644 --- a/crews/main/skills/_shared/check-session.ts +++ b/crews/main/skills/_shared/check-session.ts @@ -15,6 +15,12 @@ * douyin GET /aweme/v1/web/history/read/(a_bogus 签名)→ status_code==0 * wx_mp 不在本模块——走 wx-mp-hunter check(cgi-bin/home

「新的创作」)。 * + * pong 三态:ok / fail(明确未登录:douyin status_code=8、xhs guest)/ UNKNOWN + * (端点异常但无法证明未登录:douyin 除 0/8 外的 status_code、HTTP 层错误、网络异常)。 + * UNKNOWN 不判死——gate 放行,由下游真实请求最终裁决。2026-09-01/02 连续两晚凌晨 + * douyin pong 回 status_code=4 被判 SESSION_EXPIRED 触发误报重登,但同 cookie 真实 + * 取数成功(探活端点被风控/限流间歇拦截),见 workspace-main/douyin/20260902 排查文档。 + * * pong 结果落 ~/.cache/wiseflow-check-login/.json,TTL 600s。 * 批量调用复用同一缓存,把 N 次 pong 压成 1 次,避免批量签名触风控。 * @@ -81,7 +87,7 @@ export interface CheckResult { error?: "SESSION_EXPIRED" | "SIGN_UNAVAILABLE"; reason?: string; detail?: string; - ping?: "skipped" | "cached" | "ok" | "fail"; + ping?: "skipped" | "cached" | "ok" | "fail" | "unknown"; } /** 平台 key → 中央存储 session 文件名(xhs/xhs-browse 共用 xhs-browse.json) */ @@ -235,7 +241,7 @@ function genFakeMsToken(): string { return t + "=="; } -async function pongDouyin(map: CookieMap): Promise<{ ok: boolean; reason?: string }> { +async function pongDouyin(map: CookieMap): Promise<{ ok: boolean; reason?: string; unknown?: boolean }> { const { douyinSign } = await import("./relay-sign.ts"); const ua = loadUa("douyin"); const params = new URLSearchParams({ max_cursor: "0", count: "20", msToken: genFakeMsToken() }).toString(); @@ -246,17 +252,28 @@ async function pongDouyin(map: CookieMap): Promise<{ ok: boolean; reason?: strin headers: { "User-Agent": ua, Cookie: cookieHeader(map), Referer: "https://www.douyin.com/", Accept: "application/json" }, signal: AbortSignal.timeout(15_000), }); - if (!resp.ok) return { ok: false, reason: `history/read HTTP ${resp.status}` }; - const data = (await resp.json()) as { status_code?: number }; - // status_code==0 已登录;==8 未登录 - return data.status_code === 0 ? { ok: true } : { ok: false, reason: `status_code=${data.status_code}` }; + // HTTP 层异常不能证明未登录(风控/限流同样落这里)——UNKNOWN 放行,真实请求裁决 + if (!resp.ok) return { ok: true, unknown: true, reason: `history/read HTTP ${resp.status}` }; + const text = await resp.text(); + let data: { status_code?: number } = {}; + try { + data = JSON.parse(text) as { status_code?: number }; + } catch { + /* 非 JSON 响应(如风控验证页)——落 UNKNOWN 分支 */ + } + // status_code==0 已登录;==8 明确未登录。其余值语义未知(2026-09 误报教训,见模块头注释): + // 除 8 外一律 UNKNOWN 放行,由下游真实请求最终裁决;响应体片段保留在 reason 供观察。 + if (data.status_code === 0) return { ok: true }; + if (data.status_code === 8) return { ok: false, reason: "status_code=8(未登录)" }; + return { ok: true, unknown: true, reason: `status_code=${data.status_code}(语义未知) body=${text.slice(0, 120)}` }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); - return { ok: false, reason: `history/read error: ${msg.slice(0, 120)}` }; + // 网络异常同理不能证明未登录——UNKNOWN 放行(真实请求若同样失败会以自身错误上报) + return { ok: true, unknown: true, reason: `history/read error: ${msg.slice(0, 120)}` }; } } -async function pong(platform: string, map: CookieMap): Promise<{ ok: boolean; reason?: string }> { +async function pong(platform: string, map: CookieMap): Promise<{ ok: boolean; reason?: string; unknown?: boolean }> { const p = pongPlatform(platform); switch (p) { case "bilibili": return pongBilibili(map); @@ -272,6 +289,7 @@ async function pong(platform: string, map: CookieMap): Promise<{ ok: boolean; re interface CacheEntry { ok: boolean; reason?: string; + unknown?: boolean; at: number; } @@ -324,8 +342,12 @@ export async function verifyCookies(platform: string, map: CookieMap, opts: { no } const r = await pong(platform, map); - writeCache(platform, { ok: r.ok, reason: r.reason, at: Date.now() }); - if (r.ok) return { ok: true, detail: pres.detail, ping: "ok" }; + writeCache(platform, { ok: r.ok, reason: r.reason, unknown: r.unknown, at: Date.now() }); + if (r.ok) { + // UNKNOWN(pong 端点异常但无法证明未登录)——放行,detail 带原因供日志观察 + if (r.unknown) return { ok: true, ping: "unknown", detail: `pong UNKNOWN: ${r.reason}(放行,由真实请求最终裁决)` }; + return { ok: true, detail: pres.detail, ping: "ok" }; + } return { ok: false, error: "SESSION_EXPIRED", reason: r.reason, ping: "fail" }; } @@ -365,12 +387,21 @@ export async function checkSession(platform: string, opts: { noPing?: boolean } // Tier 2: pong(带缓存) const cached = readCache(platform); if (cached) { - if (cached.ok) return { ok: true, detail: pres.detail, ping: "cached" }; + if (cached.ok) { + if (cached.unknown) { + return { ok: true, ping: "unknown", detail: `pong UNKNOWN(缓存): ${cached.reason}(放行,由真实请求最终裁决)` }; + } + return { ok: true, detail: pres.detail, ping: "cached" }; + } return { ok: false, error: "SESSION_EXPIRED", reason: cached.reason, ping: "cached" }; } const r = await pong(platform, loaded.map); - writeCache(platform, { ok: r.ok, reason: r.reason, at: Date.now() }); - if (r.ok) return { ok: true, detail: pres.detail, ping: "ok" }; + writeCache(platform, { ok: r.ok, reason: r.reason, unknown: r.unknown, at: Date.now() }); + if (r.ok) { + // UNKNOWN(pong 端点异常但无法证明未登录)——放行,detail 带原因供日志观察 + if (r.unknown) return { ok: true, ping: "unknown", detail: `pong UNKNOWN: ${r.reason}(放行,由真实请求最终裁决)` }; + return { ok: true, detail: pres.detail, ping: "ok" }; + } return { ok: false, error: "SESSION_EXPIRED", reason: r.reason, ping: "fail" }; } diff --git a/crews/main/skills/_shared/volc_asr.py b/crews/main/skills/_shared/volc_asr.py index d82ed0e3..f4e9ceb2 100644 --- a/crews/main/skills/_shared/volc_asr.py +++ b/crews/main/skills/_shared/volc_asr.py @@ -2,7 +2,7 @@ 抽出前散在三处: - crews/main/skills/talking-head-cut/scripts/cut_plan.py 的 volc_asr() - - crews/content-producer/skills/video-producer/scripts/narration-align.py 的 fallback_asr() + - crews/content-producer/skills/expert-video/tools/video-producer/scripts/narration-align.py 的 fallback_asr() - crews/main/skills/viral-chaser/scripts/transcriber.ts 的 PYTHON_SCRIPT 内联段 三方调同一火山接口(volc.bigasr.auc_turbo),凭据同池: diff --git a/crews/main/skills/expert-bd/workflows/comment-engagement.md b/crews/main/skills/expert-bd/workflows/comment-engagement.md index 84d4ded7..7921cbee 100644 --- a/crews/main/skills/expert-bd/workflows/comment-engagement.md +++ b/crews/main/skills/expert-bd/workflows/comment-engagement.md @@ -36,7 +36,7 @@ ### Step 3: 逐内容互动 -对每个搜索到的内容,按配置的互动策略执行。通用要求:输入使用 `type` + `slowly: true`,不要用 `fill()`。 +对每个搜索到的内容,按配置的互动策略执行。通用要求:输入使用 `type` + `slowly: true`,不要用 `fill()`。**X/Twitter 例外**:评论/回复含中文/日文/韩文时**禁用 `type`**(camoufox-cli `type` 逐字符按键流与 X Draft.js 异步处理竞态,中文实测丢字+乱序)——按 `expert-twitter/tools/twitter-post/SKILL.md`「CJK 正文输入与校验闸门」改用 eval + `document.execCommand("insertText")` + 发布前校验 MATCH。X 回复应走平台表中 `twitter-post` Reply workflow,同样适用该闸门。 #### 策略 A:直接留言(direct_comment) diff --git a/crews/main/skills/expert-douyin/SKILL.md b/crews/main/skills/expert-douyin/SKILL.md index c6318ff3..97d30f37 100644 --- a/crews/main/skills/expert-douyin/SKILL.md +++ b/crews/main/skills/expert-douyin/SKILL.md @@ -1,12 +1,12 @@ --- name: expert-douyin -description: 抖音短视频运营专家。承接从定位起号、选题脚本、内容制作、发布到数据复盘的完整运营工作。零散的发布、拆解参考视频、取数等操作也可以直接做。 +description: 抖音账号运营专家。承接定位起号、内容 DNA(视频 / 图文两套框架)、选题与包装、图文生产、已有素材轻加工、视频全案 Brief 与口播文案、发布与数据复盘;全片制作委托 content-producer。 metadata: openclaw: emoji: 🎵 --- -# 抖音短视频运营专家 +# 抖音账号运营专家 ## 预设 Workflow @@ -14,8 +14,8 @@ metadata: | 场景 | Workflow | 什么时候触发 | |------|----------|-------------| -| 内容 DNA 管理 | Style DNA | 建 / 更新内容 DNA(样本、偏好、局部借鉴、对标融合),决定样本落到哪个 DNA | -| 内容生产 | Content Production | 做一条 / 做几条抖音视频;输入可以是粗略想法、参考视频(仿照 / 同主题改写)、已有素材或已有脚本 | +| 内容 DNA 管理 | Style DNA | 建 / 更新内容 DNA(样本、偏好、局部借鉴、对标融合):先判作品类型,再决定样本落到哪个 DNA | +| 内容生产 | Content Production | 做一条 / 做几条抖音内容;main 直接做已有素材轻加工,视频全案只产出Brief并委托content-producer | | 起号与定位 | Account Setup | 新号起号、定位梳理、内容支柱搭建、老号接手与诊断 | | 账号对标 | Account Benchmark | 对标账号 / 对标视频分析,并与默认或指定 DNA 逐项比较 | | 改片与调整 | Editing | 改文案、重剪、换封面、调结构、换风格 | @@ -37,17 +37,21 @@ metadata: | 工具 | 用途 | 命令 | |------|------|------| -| `douyin-style-profiler` | 生成单条视频 17 维 DNA report,并聚合 DNA 文档与 DNA template | `douyin-style-profiler` | +| `douyin-style-profiler` | 生成单篇作品(视频 / 图文,`--kind`)的 DNA report,并聚合 DNA 文档与 template(视频 = Brief + 口播文案模板;图文 = 写作模板) | `douyin-style-profiler` | | `douyin-comments` | 抓取抖音视频评论(对标分析 / 标签反推用,纯 HTTP 不起浏览器) | `douyin-comments` | | `douyin-publish` | 成片 → 抖音创作者中心发布(浏览器自动化) | `douyin-publish` | 跨领域通用技能:`viral-chaser`(抖音 / B站 / 小红书视频下载拆解,DNA 采样与仿写参考的取数主力)、`smart-search`(跨平台搜索,选题调研优先走社交平台,不用通用搜索引擎)、`content-calibrator`(DNA 表现评估)、`published-track`(发布记录与指标库)、`login-manager`(抖音登录态维护)。 -制作链相关技能(边界见 Content Production Workflow):`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`aigc-video-gen`(AIGC 片段)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。从零出脚本、端到端制作一律委托 `content-producer`,main 不代写完整脚本。 +素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 + +**视频全案分工硬边界**:main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营,也直接做图文内容与已有素材轻加工;视频全案的成片制作委托 `content-producer`。口播类视频的口播文案由 main 按 `narration-script` 子模块写好并随 Brief 交付(真人口播时,指导用户录音并取得录音文件),CP 不重写策略文案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。Brief **不含 DNA 信息**,main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 ## 风格与 DNA -账号内容风格 DNA 存储目录是 `douyin/dna/`。未指定 DNA 时默认使用并更新 `dna-0`。生产前同时读取 DNA 文档与 DNA template;对标分析先建立独立对标 DNA,不默认写入 `dna-0`。DNA 维度框架(17 维,初始版本已确认)位于 `douyin-style-profiler` 的 `references/style-17d-framework.md`。 +DNA 存储目录是 `douyin/dna/`。未指定 DNA 时默认使用并更新 `dna-0`(视频);图文另建 dna-id(如 `dna-0-note`)。生产前同时读取 DNA 文档与 DNA template;对标分析先建立独立对标 DNA,不默认写入 `dna-0`。 + +DNA 是**从一批作品样本提取并聚合出的内容生产规则集**:视频 10 维——选题与观看理由、标题与封面、内容创意、**业务植入套路**、**互动引导与 CTA 套路**、视频内容形态与制作指向、制作规格与视听倾向,加可选的口播文案子模块与账号运营子模块(简介写法、内容形式比例、发布习惯);图文 9 维——选题、标题与封面图组、内容创意、正文表达与语气、图组视觉、**业务植入套路**、**互动引导与 CTA 套路**,加账号运营子模块。它指导 main agent 出图文内容或视频 Brief(+ 口播文案),不规定创作细节与成片制作。维度框架 v2 位于 `douyin-style-profiler` 的 `references/video-dna-framework.md` 与 `references/note-dna-framework.md`。 ## 数据与记录 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md index 418ecaf5..afa7df61 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/SKILL.md @@ -1,28 +1,44 @@ --- name: douyin-style-profiler -description: 为单条抖音视频提取 17 维 DNA report,按 DNA ID 聚合历史 report 生成 DNA 文档,并推导完整的 DNA template。 +description: 提取抖音作品 DNA:单篇作品(视频 / 图文)生成 report,按 dna-id 聚合选题、标题与封面、内容创意、业务植入套路、互动引导与 CTA 套路、视频形态与制作指向、制作规格、口播文案子模块与账号运营子模块,推导 main agent 的 Brief / 图文生产 template。 +metadata: + openclaw: + emoji: 🧬 --- # douyin-style-profiler -抖音短视频的内容风格提取与 DNA 生产工具。输入是**视频的转录文本**(口播转录全文 + 标题/描述/时长/互动线索,由 Agent 先行整理成 `.md` / `.txt`)与封面 / 首帧图;不接受视频文件或视频链接作为输入。 +抖音作品 DNA 提取与聚合工具。输入是**视频转录文本**(口播全文 + 标题 / 描述 / 时长 / 互动线索)或**图文文本**(标题 + 正文 + 话题标签),由 Agent 先整理成 `.md` / `.txt`;可选封面 / 首帧 / 配图作为视觉证据。不接受视频文件或链接作为直接输入(取数走 `viral-chaser` 或平台工具)。 + +DNA 的用途是指导 main agent 选题、包装、出内容或出视频制作 Brief;**不指导成片制作**——创作细节、脚本结构、镜头与编码参数归 Content Producer。 + +## 作品类型(`--kind`) + +抖音有两套维度框架,用 `--kind` 选择;**一个 `dna-id` 只承载一种作品类型**: + +| kind | 作品 | 维度框架 | 默认 dna-id | +|------|------|----------|-------------| +| `video` | 视频 | `references/video-dna-framework.md` | `dna-0` | +| `note` | 图文 | `references/note-dna-framework.md` | `dna-0-note` | + +- 不传 `--kind` 时默认 `video`(抖音的主作品类型)。 +- `build` / `update` 会校验同一 DNA 目录下 report 的 kind 一致,混型直接报错——另一种类型请另建 dna-id。 +- 默认 dna-id 只是约定:用户可以指定任意 dna-id,脚本不强制命名。 ## 产物模型 ```text -单条视频 -> DNA report -同一个 DNA 目录下的全部 report + 权重/focus -> DNA 文档 +单篇作品 -> DNA report +同一 dna-id 下全部 report + 权重 / focus + 用户输入转译 -> DNA 文档 DNA 文档 -> DNA template ``` -- **DNA report**:单条视频的 17 维提取结果。 -- **DNA 文档**:聚合历史 report 后得到的内容与风格规则。 -- **DNA template**:由 DNA 文档推导出的生产模板,供生产时直接执行。 +- **DNA report**:单篇作品的样本观测 + 维度提取结果;跨篇共性与生产规则由聚合阶段给出,report 本身不是模板。 +- **DNA 文档**:聚合后的生产规则、样本覆盖度、子模块结论与用户输入转译区。 +- **DNA template**:main agent 的生产输入模板(视频 = Brief 正文 + 口播文案;图文 = 写作模板)。 ## 存储结构 -DNA 以 DNA ID 为主体存储,一个 DNA 可以持续放入任意数量视频,样本可以来自一个或多个参考账号,甚至来自用户直接的想法。 - ```text douyin/dna/{dna-id}/ reports/ @@ -33,176 +49,171 @@ douyin/dna/{dna-id}/ {dna-id}.template.md ``` -原始转录文本可以临时来自任何位置(建议 `douyin/ref/{dna-id}/transcripts/`);生成后的 DNA report 必须进入对应 DNA 的 `reports/` 目录。 +原始转录 / 正文文本可临时放在 `douyin/ref/{dna-id}/transcripts/`;生成后的 report 必须进入目标 DNA 的 `reports/` 目录。 ## 职责边界 -- 输入转录文本支持 `.md` / `.txt`;视频文件、链接不直接作为输入。 -- 统计只作为聚合证据底座,不评分、不替代定性判断。 -- 17 维语义判断由 Agent 回读转录原文(必要时回看视频关键帧)完成;封面 / 首帧维度必须由视觉模型读取本地图片完成。 -- 不输出选题价值判断、合规结论、账号权重或风格评分。 -- 不要求用户确认或登记 INDEX。 +- 抖音视频与图文都常见:先判作品类型再选框架,不要把图文笔记塞进视频 DNA。 +- 单篇 report 只提供候选信号,不判断跨篇稳定性;共性、偏好、孤例由聚合阶段判断。 +- 视觉维度必须有图片 / 关键帧证据,由视觉模型读取;缺失写「未提供」,不得凭文本想象补齐。 +- 口播文案子模块只在口播类作品启用;样本不足写「未启用」。 +- 账号运营子模块(简介写法、内容形式比例、发布习惯)只在样本来自对标账号批量提取时填写,且**不进 template**。 +- 不输出风格评分或账号权重;合规只记「必须避免项」(虚假承诺、利益诱导互动、隐藏站外联系方式、谐音绕检测),不出具合规审查结论。 -## Report - 单条提取 +## Report — 单篇提取 ```bash +# 视频样本(不传 --kind,默认 video) douyin-style-profiler report \ --input path/to/transcript.md \ --dna-id {dna-id} \ --sample-id {sample-id} \ + --duration 36 \ --cover-image path/to/cover.jpg \ --source-url "https://www.douyin.com/video/..." \ - --duration 89 -``` - -默认输出: + --output-dir douyin/dna/{dna-id}/reports -```text -douyin/dna/{dna-id}/reports/{sample-id}.report.md +# 图文样本 +douyin-style-profiler report \ + --input path/to/note.md \ + --kind note \ + --dna-id {dna-id}-note \ + --sample-id {sample-id} \ + --cover-image path/to/cover.jpg \ + --output-dir douyin/dna/{dna-id}-note/reports ``` -参数说明: - -- `--cover-image`:封面或首帧本地图片(抽帧产物或封面下载图),进入视觉模型分析。 -- `--source-url`:原视频链接,作为报告证据保留;本地素材无链接时省略。 -- `--duration`:视频时长(秒),用于口播密度统计;未知时省略。 +- `--kind`:作品类型(见上);不传走默认。 +- `--cover-image`:封面 / 首帧 / 首图本地文件,作为视觉证据(自动拷进 `covers/`)。 +- `--source-url`:原作品链接;本地素材无链接时省略。 +- `--duration`:视频时长(秒),用于口播密度统计;图文忽略。 +- `--weight`:样本权重,默认 1。 +- `--focus`:限制该样本只影响指定维度 ID,可重复传入。 -可配置权重: +Agent 生成 scaffold 后必须: -```bash ---weight 3 -``` +1. 补齐「样本观测」:作品类型、样本来源、账号与简介、发布时间、数据线索、素材来源与授权。 +2. 回读原文,补齐各维度的单篇结论、原文证据与可复用信号。 +3. 视频形态必须给出**制作指向**(Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能),只写真实存在的资源名。 +4. 账号运营子模块无法从单篇观测时写「未观测」。 +5. 高数据样本必须回读创意与形态再归因,不得只凭播放 / 阅读量下结论。 -可限制该条只在某些维度参与借鉴: +## Build — 聚合 DNA 文档与模板 ```bash ---focus hook ---focus speech-rhythm +douyin-style-profiler build --dna-id {dna-id} # 视频 DNA(默认 kind=video) +douyin-style-profiler build --dna-id {dna-id}-note --kind note # 图文 DNA ``` -Agent 生成 scaffold 后必须回读转录原文,补齐每个维度的单条结论、原文证据和可复用创作信号;钩子维度必须逐字摘录前 3 秒口播 / 首帧字幕。 -封面 / 首帧维度必须读取 `--cover-image` 指向的本地图片,并通过视觉模型补齐主体、构图、色彩、光线、质感、风格、文字视觉、品牌元素、避免项和 AIGC 复现提示词要素。没有图片时记录"未提供",不得编造。 - -## Build - 聚合 DNA 文档与模板 +默认读取 `douyin/dna/{dna-id}/reports/`,输出 `{dna-id}.dna.md` 与 `{dna-id}.template.md`。也可显式传 report 文件 / 目录: ```bash -douyin-style-profiler build --dna-id {dna-id} +douyin-style-profiler build --input path/to/reports --dna-id {dna-id} ``` -默认读取: - -```text -douyin/dna/{dna-id}/reports/ -``` - -默认输出: +Agent 聚合时必须: -```text -douyin/dna/{dna-id}/{dna-id}.dna.md -douyin/dna/{dna-id}/{dna-id}.template.md -``` +1. 读取全部 DNA report,不能只看统计表。 +2. 按 `weight` 与 `focus` 判断影响范围。 +3. 区分高覆盖共性、高权重偏好、局部借鉴、孤例与例外。 +4. 标注样本覆盖度;少量样本不得称为稳定结论。 +5. 为每个维度写聚合结论、报告依据与可执行创作规则。 +6. 确保 DNA 文档能完整推导 template(template 不得引入 DNA 文档未确认的规则)。 -也可显式传入一个或多个 report 文件/目录: +## Update — 增量聚合 ```bash -douyin-style-profiler build \ - --input path/to/reports \ - --dna-id {dna-id} +douyin-style-profiler update \ + --input douyin/dna/{dna-id}/reports/{new-sample}.report.md \ + --dna douyin/dna/{dna-id}/{dna-id}.dna.md \ + --template douyin/dna/{dna-id}/{dna-id}.template.md ``` -Agent 聚合时必须: +脚本合并历史 report 与新 report、重算统计并保留 Agent 已写内容;kind 从 DNA 文档 frontmatter 继承(也可用 `--kind` 显式指定,冲突时报错)。Agent 仍需重新审视聚合结论并同步修订 template。 -1. 读取全部 DNA report,不能只看统计表。 -2. 按每个 report 的 `weight` 和 `focus` 判断影响范围。 -3. 区分高频共性、高权重偏好、局部借鉴、孤例和例外。 -4. 为每个维度写聚合结论、报告依据和创作规则。 -5. 确保 DNA 文档能够完整推导 template。 +`--input` 可省略,用于只融合用户输入或另一个 DNA 的局部规则;此时必须传 `--user-input`。 ## DNA Template -Template 是生产模板,不是概念解释。必须从 DNA 文档的 17 个维度推导,至少包含: - -- 选题角度、受众关系 -- 标题类型、参考标题、话题标签策略、封面 / 首帧风格和封面 AIGC 提示词要素 -- 起、承、转、合、CTA 五个固定语义部分(对应短视频的黄金开场、主体推进、高潮转折、收尾、互动引导) -- 每个部分的本段任务、执行方式和必须做 / 避免项 - -模板整体固定为七个部分:选题、标题(含封面)、起、承、转、合、CTA。固定的是语义结构,不是物理时长占比;任一部分可以对应视频中的一个或多个段落,也可以在特定内容形态下弱化。 - -五个视频部分必须充分吸收 DNA 文档中的维度结论: +**视频作品 template(= Brief 正文模板 + 口播文案模板)** -| 部分 | 主要推导来源 | -| --- | --- | -| 起 | 3秒钩子、选题角度、口播节奏、语气与人设感、镜头语言 | -| 承 | 视频结构模式、叙事节奏、口播节奏、用词习惯、专业度体现、镜头语言 | -| 转 | 冲突与张力、叙事节奏、情绪表达(语气与人设感)、口播节奏 | -| 合 | 视频结构模式、签名式标记、语气与人设感、系列化与合集 | -| CTA | 互动设计、选题与受众关联、语气与人设感 | +1. 选题 +2. 标题与封面 +3. 内容创意 +4. 业务植入与 CTA +5. 视频形态与制作指向 +6. 制作规格 +7. 口播文案 -## Update - 增量聚合 +**图文作品 template(= 图文写作模板)** -```bash -douyin-style-profiler update \ - --input douyin/dna/{dna-id}/reports/{new-sample}.report.md \ - --dna douyin/dna/{dna-id}/{dna-id}.dna.md \ - --template douyin/dna/{dna-id}/{dna-id}.template.md -``` +1. 选题 +2. 标题与封面 +3. 内容创意与结构 +4. 正文表达 +5. 图组 +6. 业务植入与 CTA -脚本会合并 DNA 文档记录的历史 report 与新 report,重新计算加权统计,并保留 Agent 已完成内容。Agent 仍需重新审视聚合结论,再同步修订 DNA 文档和 template。 - -`--input` 可省略。省略时表示没有新增样本,只基于历史 report、既有 DNA 文档和用户输入做融合;适用于采纳另一个 DNA 文档或 template 中的局部规则。此时仍必须通过 `--user-input` 传入要融合的要求,并由 Agent 转译到具体维度。 +- 开头两段(**选题**、**标题与封面**)跨平台通用。 +- 视频 template 的各段直接对应 Brief 正文字段;**Brief 不含 DNA 信息**(Content Producer 看不到 main 的 DNA),素材清单与授权、验收标准、闸门批准人按平台 Content Production Workflow 填。 +- 账号运营子模块不进 template,只留在 DNA 文档。 ## 用户输入转译 -用户输入是参考信息,不是可直接入库的 DNA 规则。 +用户输入是参考信息,不是可直接入库的 DNA 规则: ```bash ---user-input "开头冲突再前置一点" +--user-input "以后多做实拍拼接,少用纯动画" ``` -Agent 必须把输入转译到具体维度,例如: +Agent 必须映射到具体维度并转译为可执行规则,例如: ```text -hook:钩子改为矛盾前置,第一句直接给反差结果 -video-structure:开场压缩到 2 秒内进入冲突画面 -speech-rhythm:开场两句均为短句,不做背景铺垫 +video-form:主形态为影视解说 + 反转植入,制作指向 expert-video 的 Reversal Ad workflow +production-spec:竖屏 9:16、时长带 30-45s、画面偏冷暗解说段 + 明亮植入段 +narration-script:口播保持第三人称解说体,句长 10-15 字 ``` 处理要求: -1. 在 DNA 文档的"用户输入转译区"记录 affected dimensions、DNA 修改和 template 修改。 -2. 把原话转译成可执行的聚合结论与创作规则。 -3. Template 只写转译后的执行规则,不直接抄用户原话。 -4. 与样本证据冲突时保留冲突说明,由用户选择优先级。 +1. 在 DNA 文档「用户输入转译区」记录 raw_input、affected dimensions、DNA 修改、template 修改与状态。 +2. 原话必须转译为可执行规则,不得直接抄进 template。 +3. 与样本证据冲突时保留冲突说明,由用户选择优先级。 ## Focus ID +### video(视频) + +| ID | 维度 | +|---|---| +| `topic-angle` | 选题与观看理由 | +| `title-cover` | 标题与封面 | +| `content-idea` | 内容创意 | +| `video-form` | 视频内容形态与制作指向 | +| `production-spec` | 制作规格与视听倾向 | +| `biz-implant` | 业务植入套路 | +| `interaction-cta` | 互动引导与 CTA 套路 | +| `narration-script` | 口播文案子DNA | +| `account-bio` | 账号简介写法 | +| `content-mix-cadence` | 内容形式比例与发布习惯 | + +### note(图文) + | ID | 维度 | |---|---| -| `topic-angle` | 选题角度 | -| `title-style` | 标题与文案 | -| `cover-frame` | 封面与首帧 | -| `hook` | 3秒钩子 | -| `word-habit` | 用词习惯 | -| `speech-rhythm` | 口播节奏 | -| `tone` | 语气与人设感 | -| `shot-language` | 镜头语言 | -| `visual-style` | 画面风格 | -| `sound-design` | 声音与BGM | -| `video-structure` | 视频结构模式 | -| `narrative-rhythm` | 叙事节奏 | -| `conflict-tension` | 冲突与张力 | -| `professionalism` | 专业度体现 | -| `interaction-design` | 互动设计 | -| `signature` | 签名式标记 | -| `series-design` | 系列化与合集 | - -## 统计与分词 - -脚本统计转录文本的句段、标点、人称、口播密度(需 `--duration`)等指标;中文高频信号使用相邻二字组合,仅作为候选线索。Agent 必须回读原文判断它是否真是口头禅或签名式表达。 - -当前不引入 jieba。若后续候选噪声明显,可把 jieba 作为候选词挖掘器加入仓库级依赖,但分词结果不能直接作为 DNA 结论。 +| `topic-angle` | 选题与观看理由 | +| `title-cover` | 标题与封面图组 | +| `content-idea` | 内容创意 | +| `body-voice` | 正文表达与语气 | +| `imageset-visual` | 图组视觉风格 | +| `biz-implant` | 业务植入套路 | +| `interaction-cta` | 互动引导与 CTA 套路 | +| `account-bio` | 账号简介写法 | +| `content-mix-cadence` | 内容形式比例与发布习惯 | + +`--focus` 按作品类型校验:视频 report 不接受图文维度 ID,反之亦然。 ## 参考资料 -- `references/style-17d-framework.md`(抖音 17 维 DNA 分析框架,初始版本已确认) +- `references/video-dna-framework.md`(抖音视频作品 DNA 框架 v2:维度定义、制作指向映射、聚合边界与 Focus ID) +- `references/note-dna-framework.md`(抖音图文作品 DNA 框架 v2:维度定义、制作指向映射、聚合边界与 Focus ID) diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/note-dna-framework.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/note-dna-framework.md new file mode 100644 index 00000000..2ae146df --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/note-dna-framework.md @@ -0,0 +1,103 @@ +# 抖音图文作品 DNA 框架(v2) + +> 状态:v2(2026-09-10 修订:补业务植入套路 `biz-implant` 与互动引导 / CTA 套路 `interaction-cta` 两维)。Focus ID、维度命名与 template 语义以本文件为准;调整维度必须升版本,并同步 `scripts/build_style_profile.py` 与工具 `SKILL.md`。 +> 抖音有两套框架:视频作品用 `video-dna-framework.md`,本文件是**图文作品**框架。 + +## 定位与边界 + +DNA 是**从一批作品样本中提取、聚合出的内容生产规则集**:它回答「这类作品怎么选、怎么包装、业务怎么植入、怎么引导行动」,不是账号人设说明书。生产方式与其他平台完全一致,仍是三层产物: + +```text +单篇作品 -> DNA report(本框架的维度逐项提取) +同一 dna-id 下全部 report + 权重 / focus + 用户输入转译 -> DNA 文档 +DNA 文档 -> DNA template +``` + +- 同一批样本可以来自多个不同账号,也可以来自用户指定的一个账号(从其发布列表批量提取);是否归入同一个 DNA 由用户指定,未指定入 `dna-0`。 +- 一个 `dna-id` 只承载一种作品类型:抖音默认 dna-0 为视频,图文样本请另建 dna-id(如 dna-0-note)。 + +### 进 DNA 与不进 DNA + +| 进 DNA | 不进 DNA | +|--------|----------| +| 选题与观看理由、标题与封面写法、内容创意原型、正文表达与语气、图组视觉、业务植入套路、互动引导与 CTA 套路、(对标账号)账号运营子模块 | 创作细节、脚本结构、逐句台词、镜头表、转场与编码参数——那些归 Content Producer | + +图文 DNA 的用途是指导 main agent 直接生产图文作品;template 就是图文写作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 + +## 维度(9 维) + +### 一、选题与包装 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 1 | `topic-angle` | 选题与观看理由 | 选题类型、选题入口(现象 / 问题 / 冲突 / 数据 / 热点 / 挑战 / 个人经历)、目标人群为什么要看完(理解 / 判断 / 行动 / 避坑 / 身份认同 / 情绪共鸣)、系列与内容支柱归属 | +| 2 | `title-cover` | 标题与封面图组 | 标题类型与原文、封面承诺(用户点进来的可见理由)、版式(大字报 / 实拍场景 / 对比图 / 清单卡片)、色彩与文字视觉、封面与图组风格一致性、AIGC 复现要素 | + +### 二、内容创意 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 3 | `content-idea` | 内容创意 | 一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点;这个套路换主题还能怎么用 | + +### 三、正文与视觉 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 4 | `body-voice` | 正文表达与语气 | 开头钩子、正文组织方式(清单体 / 教程步骤 / 故事线 / 对比 / 观点输出)、分行与段落节奏、口语化程度与人称、emoji 与标点用法、签名式表达 | +| 5 | `imageset-visual` | 图组视觉风格 | 图片数量与顺序、构图类型(产品展示 / 场景 / 文字卡片 / 对比图 / 过程图)、图文信息分工、色调与质感、版式一致性、文字视觉 | + +### 四、业务植入与转化 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 6 | `biz-implant` | 业务植入套路 | 是否有业务植入(纯内容 / 软植入 / 硬广直给)、植入位置(开头 / 中段某一步 / 结尾 / 图组某一图 / 评论区自评)、植入载体(案例与数据、清单第 N 项、教程步骤内嵌、痛点故事转折、对比实测、产品截图、体验记录)、植入方式原型(痛点→方案 / 场景带入 / 实测对比 / 清单第 N 项 / 身份认同 / 口碑故事 / 教程内嵌 / 硬广直给)、内容与业务的衔接句(转折句原文)、植入密度与占比(正文字数占比、品牌词与产品名出现次数与方式) | +| 7 | `interaction-cta` | 互动引导与 CTA 套路 | 行动目标(评论 / 收藏 / 关注 / 进店 / 咨询 / 搜索品牌词)与本篇主目标、CTA 位置与时机(首段钩子后 / 正文中段 / 结尾 / 图组末图 / 评论区自评)、CTA 句式与原文摘录(命令式 / 提问式 / 利益式 / 身份式)、一篇放几个行动、诱因设计(利益点 / 情绪 / 身份认同 / 稀缺)、话题标签承载的意图、与业务转化目标的对应、合规边界 | + +### 五、账号运营子模块(对标账号样本才有) + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 8 | `account-bio` | 账号简介写法 | 账号昵称、简介写法、主页与置顶表达、对外承诺(仅对标账号样本可得) | +| 9 | `content-mix-cadence` | 内容形式比例与发布习惯 | 图文 / 视频等内容形式比例、发布时间段与频率、内容形式混合节奏(如三篇图文对一篇视频)、栏目化节奏(仅对标账号批量样本可得) | + +## Report 与聚合规则 + +1. 单篇 report 先填「样本观测」:作品类型、样本来源、账号与简介、发布时间、数据线索、图片数量与来源、关键词与标签。缺失一律写「未观测」,不得编造。 +2. 单篇 report 不判断跨篇稳定性;聚合时区分高覆盖共性、高权重样本偏好、局部借鉴(focus)、孤例与例外,并标注样本覆盖度。 +3. 视觉维度(封面 / 首帧 / 图组)必须有图片证据,由视觉模型读取本地图片后反推 AIGC 复现要素;没有图片写「未提供」。 +4. 正文表达维度里的口头禅与签名表达只是候选线索,必须回读原文确认后才能进 DNA 文档。 +5. 账号运营子模块(`account-bio`、`content-mix-cadence`)只在样本来自对标账号批量提取时填写;用户只提供单篇时写「未观测」。 +6. 脚本统计只作证据底座(标题字数、正文行数、句长、问句密度、emoji 密度、话题标签数),不生成总分、不判定风格是否合格。 +7. 业务植入与 CTA 两个维度必须给出**位置 + 载体 + 原文摘录**三样证据;只写「自然植入」「引导关注」这类空泛结论不算提取完成。本篇没有业务植入时明确写「无植入(纯内容)」,不得留空;平台红线(虚假承诺、利益诱导互动、隐藏站外联系方式、谐音绕检测)一律记为必须避免项。 +8. 高数据样本必须回读创意、结构与关键词再归因,不得把高阅读直接等同于风格好。 + +## Template 语义 + +DNA template = **图文写作模板**,固定语义段如下(脚本 `build` / `update` 自动生成骨架,字段由 Agent 从 DNA 文档推导填写): + +1. **[选题部分]** — 选题角度推荐、选题需考虑的受众关联角度、内容支柱与系列关系、避免 +2. **[标题与封面部分]** — 标题类型、参考标题、封面或首帧风格、封面 AIGC 提示词要素、话题标签策略 +3. **[内容创意与结构部分]** — 创意原型、正文组织方式、信息密度、记忆点、避免 +4. **[正文表达部分]** — 开头钩子、推进方式、段落与分行节奏、人称与语气、emoji 与标点、签名式表达、必须做、避免 +5. **[图组部分]** — 图片数量与顺序、构图类型、色调与质感、版式一致性、文字视觉、AIGC 提示词要素 +6. **[业务植入与 CTA部分]** — 植入位置与时机、植入载体与方式原型、内容与业务的衔接句、植入密度与占比、CTA 主目标、CTA 位置与句式、诱因与合规红线、避免 + +- 开头两段(**选题**、**标题与封面**)跨平台通用:任何生产都先解决「做什么」和「怎么命名、怎么呈现封面」。 +- `[图组部分]` 与 `[正文表达部分]` 是两条并行的生产轨道:图组规则管视觉,正文规则管文字。 +- 账号运营子模块不进 template:`account-bio` 与 `content-mix-cadence` 的结论只留在 DNA 文档,供起号、对标与发布节奏决策使用。 +- template 必须能从 DNA 文档推导,不得引入 DNA 文档未确认的规则;用户输入必须先映射到维度再转译为执行规则。 +- 分段数量由 DNA 文档的结构结论决定,不固定为三段或四段;固定的是语义部分,不是物理段落数量。 + +## Focus ID 表 + +`--focus` 只接受本框架的维度 ID;限制某篇样本只影响这些维度。 + +| 分组 | Focus ID | +|------|----------| +| 选题与包装 | `topic-angle` `title-cover` | +| 内容创意 | `content-idea` | +| 正文与视觉 | `body-voice` `imageset-visual` | +| 业务植入与转化 | `biz-implant` `interaction-cta` | +| 账号运营子模块(对标账号样本才有) | `account-bio` `content-mix-cadence` | + +focus 校验按作品类型执行:视频 report 不接受图文维度 ID,反之亦然。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md deleted file mode 100644 index d90c9592..00000000 --- a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/style-17d-framework.md +++ /dev/null @@ -1,295 +0,0 @@ -# 抖音 17 维 DNA 分析框架 - -> **状态:初始版本(2026-08-27 经用户确认)**。后续如需增删合并维度,必须同步更新 `scripts/build_style_profile.py` 的 `DIMENSION_GROUPS` 与 Focus ID 表,并对已有 DNA 重新聚合。 - -本框架用于分析抖音短视频作品的内容与风格,逐步累积形成抖音内容生产 DNA。与微信公众号 17 维不同,短视频的观测物是**口播转录文本 + 画面 + 声音 + 结构节奏**的组合,因此维度按短视频语境重新组织。分析产物必须是三层: - -1. **DNA report**:单条视频的 17 维提取结果。 -2. **DNA 文档**:聚合同一个 DNA ID 下全部 report 后形成的规则。 -3. **DNA template**:从 DNA 文档推导出的生产模板。 - -```text -单条视频 -> DNA report -多个 DNA report + 权重/focus + 用户输入转译 -> DNA 文档 -DNA 文档 -> DNA template -``` - -样本观测来源: - -- `viral-chaser` 拆解产物:ASR 转录全文与分句时间戳、视频时长、标题/描述/作者、互动计数、抽帧关键帧。 -- 用户提供的文字稿、脚本或口述要点(转成 `.md` / `.txt` 后输入)。 -- 画面维度(镜头语言、画面风格、封面 / 首帧)必须由视觉模型读取视频关键帧或封面图完成,不得凭转录文本想象补齐。 - -## 一、选题与包装(3) - -### 1. 选题角度 - -- 选题类型:痛点教程、失败复盘、公开挑战、案例拆解、反常识观点、清单避坑、答疑、场景实验、系列预告等 -- 选题入口:现象、问题、冲突、数据、热点、挑战还是个人经历 -- 观看理由:陌生人为什么要看完这条;看完能记住账号的哪个身份、冲突、热情或承诺 - -分析时必须回答: - -- 这条内容解决了什么具体问题,或提供了什么具体情绪价值。 -- 选题与目标人群的关联是什么:理解、判断、行动、避坑、身份认同还是情绪共鸣。 - -### 2. 标题与文案 - -- 标题类型:痛点型、数字型、反差型、悬念型、搜索长尾型、身份点名型 -- 标题原文与描述文案原文 -- 话题标签策略:主标签、场景词、痛点词的使用方式与数量 -- 搜索词预埋:标题与描述中的长尾搜索句 - -### 3. 封面与首帧 - -封面 / 首帧来源: - -- `viral-chaser` 抽帧关键帧(首选 `frame_00`)或封面图下载。 -- 用户手动提供本地图片文件。 -- 无法获取时记录"未提供",不得根据转录文本或标题想象补齐。 - -Agent 必须通过视觉模型读取图片,至少提取: - -| 特征 | 分析要求 | -| --- | --- | -| 画面主体与场景 | 主体是什么、在做什么、处于什么场景,主体占画面比例 | -| 构图与画幅 | 竖屏构图、主体位置、视觉动线、留白、前景背景层次 | -| 色彩体系 | 主色、辅色、强调色、饱和度、明度、对比关系 | -| 光线与质感 | 自然光 / 人造光、方向、阴影、颗粒、锐度、材质 | -| 风格与媒介 | 实拍 / 插画 / 录屏 / 图文卡片 / 混剪,具体流派或时代感 | -| 文字视觉与图文关系 | 封面字内容、字体气质、字号层级、贴纸、标题框、字图遮挡关系 | -| 品牌识别元素 | 固定角标、边框、水印、系列符号、版式惯性 | -| 避免项 | 与该封面风格相反、容易破坏识别度的元素 | - -视觉模型分析必须能反推为一套可执行 AIGC 提示词,包含主体、场景、构图、画幅、色彩、光线、质感、风格、文字视觉、品牌元素和负向约束。 - -## 二、钩子与表达(4) - -### 4. 3秒钩子 - -- 钩子类型:矛盾前置、数据冲击、场景代入、悬念留白、结果反差、身份点名、利益承诺 -- 钩子原文:前 3 秒口播逐字摘录 + 首帧字幕 -- 钩子与定位的对应关系;钩子承诺在正文中是否兑现 -- 画面、字幕、口播是否在同一秒传递同一个重点 - -### 5. 用词习惯 - -- 高频词 Top 10-15(脚本统计仅为候选线索) -- 口头禅与高频连接词 -- 情绪词偏好 - -### 6. 口播节奏 - -- 句长分布(短/中/长)与短句连发模式 -- 停顿与留白位置(结合时间戳判断) -- 口播密度(字/分钟,脚本统计需 `--duration`) -- 设问自答频率、排比/对比使用频率 - -### 7. 语气与人设感 - -- 说话方式:朋友式 / 专家式 / 陪伴成长式比例 -- 亲密度与距离感;人称使用偏好(我/你/我们) -- 人设张力:真实短板、热情、判断标准如何成为记忆点 -- 人设边界:不编造悲惨经历、虚假失败、身份标签(编造即失格,不进 DNA) - -## 三、视觉与制作(3) - -### 8. 镜头语言 - -- 出镜形态:真人口播、旁白 + 空镜、产品实拍、屏幕录制、情景演绎 -- 场景选择与记忆点(场景放大) -- 景别与机位切换频率、运镜方式 -- 动作元素:边做什么边讲(实测、挑战、拆箱、复刻、限时完成) - -### 9. 画面风格 - -- 色调与滤镜倾向(暖/冷、高饱和/低饱和) -- 字幕样式:字体粗细、位置、背景框、重点字变色放大 -- 贴纸、特效、转场的使用密度与克制程度 -- 信息密度:画面元素多寡与留白 - -### 10. 声音与BGM - -- 声音形态:原声口播、TTS 配音、旁白、纯画面 + 字幕 -- BGM 类型与节奏(卡点 / 铺底 / 无 BGM) -- 音效使用(强调音、转场音、环境声) -- 音量层次:人声与 BGM 的主从关系 - -## 四、结构与节奏(4) - -### 11. 视频结构模式 - -- 总时长与时长带(<15s / 15-60s / 1-3min / >3min) -- 开场(钩子)—主体—高潮转折—收尾的时长占比 -- 单条完整叙事还是系列切片 -- 分段方式:一气呵成还是明显小节(字幕分段 / 场景切换) - -### 12. 叙事节奏 - -- 信息密度波动:密集输出段与喘息段交替 -- 节奏切换点位置(几秒处换场景 / 换论点 / 上钩子回收) -- 高光位置:最强信息或最强情绪放在哪里 -- 完播设计:中段是否有持续悬念或递进承诺 - -### 13. 冲突与张力 - -- 放大方式:场景放大、动作放大、冲突放大、系列放大、人格放大 -- 反差与矛盾的呈现位置(前置还是后置揭晓) -- 限制条件与失败现场的使用 -- 检查项:放大层是否服务内容本身,是否只剩噱头 - -### 14. 专业度体现 - -- 证据与演示方式:实物、实操、数据、对比、案例 -- 拆解深度:结论复述还是过程与判断展示 -- 术语密度与解释方式 -- 边界标注:适用条件、样本限制、不确定性的说明习惯 - -## 五、互动与标记(3) - -### 15. 互动设计 - -- 评论引导方式:具体问题、选择题(A 还是 B)、征集案例、关键词评论 -- 引导位置与密度;是否合规(不诱导、不以利益换互动) -- 评论区经营痕迹:置顶、作者回复风格 -- 关注引导:系列承诺、主页价值、下集预告 - -### 16. 签名式标记 - -- 固定开场白 / 结束语 -- 口头禅与标志性表达片段 -- 标志性动作、道具、场景或剪辑习惯 -- 系列符号:固定角标、固定 BGM、固定字幕样式 - -### 17. 系列化与合集 - -- 系列设定:单条是否属于持续任务("7 天改造""从不会到能做") -- 集与集之间的钩子:本集结尾如何预告下一集 -- 合集归属与命名习惯 -- 内容支柱意识:系列是否服务于同一人群 / 同一能力线 - -## 分析要求 - -### DNA report 要求 - -除封面 / 首帧外,每个维度至少包含: - -1. **单条结论**:这条视频在该维度上的具体做法。 -2. **原文证据**:口播逐字引用(注明时间区间)或画面描述。 -3. **可复用创作信号**:聚合时值得进入 DNA 文档的信号。 - -单条 report 不需要判断跨条稳定性;这个判断属于 DNA 文档聚合阶段。 - -钩子维度必须逐字摘录前 3 秒口播 / 首帧字幕,不概括、不改写。 - -封面 / 首帧的 DNA report 必须记录图片路径、视觉模型分析结果、AIGC 复现提示词要素和无法判断的信息。没有图片时不得编造视觉证据。 - -### DNA 文档要求 - -除封面 / 首帧外,每个维度至少包含: - -1. **聚合结论**:当前账号采用的规则。 -2. **报告依据**:来自哪些 DNA report、各自权重和 focus。 -3. **创作规则**:生产时必须怎么做,避免只写"有网感""节奏快"这类空泛形容词。 - -聚合时要区分高频共性、高权重样本偏好和用户的明确指示。用户输入必须先映射到具体维度,再转译为聚合结论和创作规则。 - -封面 / 首帧聚合时必须输出可复用的视觉生成规则和 AIGC 提示词模板,并说明覆盖多少张封面 / 首帧、哪些元素是共性、哪些是孤例。 - -样本规则: - -- 默认所有 DNA report 权重为 1。 -- 用户可指定某条 report 权重更高,或通过 focus 限制它只影响某些维度。 -- 聚合结论必须说明依据,不把统计表直接当结论。 -- Agent 必须回读 report 与必要转录原文,避免只看数值。 -- 互动数据(播放/点赞/评论/分享/收藏)只能来自用户提供的数据线索或平台记录,不得编造;数据好坏不直接等于内容风格好坏。 - -## DNA 文档到模板的推导 - -DNA 文档完成后,必须把可复用结论转成生产模板: - -- 选题角度转成选题入口和受众关联要求。 -- 标题与文案转成标题类型偏好、话题标签策略,同时选择代表性标题作为示例直接记录到模板中。 -- 封面 / 首帧特征转成封面风格规则和 AIGC 提示词要素。 -- 其余部分按固定语义结构组织为"起、承、转、合、CTA"五个部分(对应短视频的黄金开场、主体推进、高潮转折、收尾、互动引导)。 -- 每个部分写明本段任务、执行方式、必须做和避免项。 -- 固定的是七个模板部分,不是物理时长占比;任一部分可以对应视频中的一个或多个段落,也可以在特定内容形态下弱化。 -- 每个部分的规则必须能从 DNA 文档的 17 维聚合结论推导,不得引入样本和用户输入之外的新规则。 -- 用户输入必须先映射到具体维度,再转译为模板中的执行规则。 - -七个部分与 17 维的推导关系: - -| 模板部分 | 必须吸收的 DNA 维度 | -| --- | --- | -| 选题 | 选题角度、选题与受众关联(观看理由) | -| 标题(含封面) | 标题类型、参考标题、话题标签策略、封面 / 首帧风格与 AIGC 提示词要素 | -| 起 | 3秒钩子、选题角度、口播节奏、语气与人设感、镜头语言 | -| 承 | 视频结构模式、叙事节奏、口播节奏、用词习惯、专业度体现、镜头语言、画面风格 | -| 转 | 冲突与张力、叙事节奏、语气与人设感、口播节奏、声音与BGM | -| 合 | 视频结构模式、签名式标记、语气与人设感、系列化与合集 | -| CTA | 互动设计、选题与受众关联、语气与人设感 | - -推荐形态: - -```markdown -(选题角度推荐:xxx、xx、xxx) -(选题需考虑的受众关联角度:xxx) - -[标题](类型为主:xxx、xx、xx) -(参考:xxx、xxx、xxx) -(话题标签策略:xxx) -(封面 / 首帧风格:xxx) -(封面 AIGC 提示词要素:xxx) - -[起部分] -(本段任务:在 3 秒内给出观看理由) -(钩子类型:xxx) -(开场口播:xxx) -(画面呈现:xxx) -(句式节奏:xxx) -(必须做:xxx) -(避免:xxx) - -[承部分] -(本段任务:xxx) -(推进逻辑:xxx) -(信息密度:xxx) -(镜头与画面:xxx) -(口播表达:xxx) -(证据与素材:xxx) -(必须做:xxx) -(避免:xxx) - -[转部分] -(本段任务:xxx) -(转折触发:xxx) -(冲突与反差:xxx) -(情绪表达:xxx) -(句式节奏:xxx) -(必须做:xxx) -(避免:xxx) - -[合部分] -(本段任务:xxx) -(收束方式:xxx) -(情绪落点:xxx) -(签名式标记:xxx) -(必须做:xxx) -(避免:xxx) - -[CTA部分] -(行动目标:xxx) -(表达方式:xxx) -(时机与位置:xxx) -(受众关联:xxx) -(必须做:xxx) -(避免:xxx) -``` - -## 更新规则 - -- 新视频先形成单条 DNA report,再与历史 report 合并重新聚合。 -- Agent 需要比较新旧结论:保留共性、记录变化、移动孤例。 -- 用户明确指定只参考某一块时,只更新对应维度,其他维度不得静默改写。 -- 用户输入必须转译为 affected dimensions、DNA 修改、template 修改和执行规则;原话只保留在转译来源中。 -- 更新后 DNA 文档与模板必须同步,避免两者表达两套规则。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md new file mode 100644 index 00000000..9d496675 --- /dev/null +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/references/video-dna-framework.md @@ -0,0 +1,126 @@ +# 抖音视频作品 DNA 框架(v2) + +> 状态:v2(2026-09-10 修订:补业务植入套路 `biz-implant` 与互动引导 / CTA 套路 `interaction-cta` 两维)。Focus ID、维度命名与 template 语义以本文件为准;调整维度必须升版本,并同步 `scripts/build_style_profile.py` 与工具 `SKILL.md`。 +> 抖音有两套框架:图文作品用 `note-dna-framework.md`,本文件是**视频作品**框架。 + +## 定位与边界 + +DNA 是**从一批作品样本中提取、聚合出的内容生产规则集**:它回答「这类作品怎么选、怎么包装、业务怎么植入、怎么引导行动」,不是账号人设说明书。生产方式与其他平台完全一致,仍是三层产物: + +```text +单篇作品 -> DNA report(本框架的维度逐项提取) +同一 dna-id 下全部 report + 权重 / focus + 用户输入转译 -> DNA 文档 +DNA 文档 -> DNA template +``` + +- 同一批样本可以来自多个不同账号,也可以来自用户指定的一个账号(从其发布列表批量提取);是否归入同一个 DNA 由用户指定,未指定入 `dna-0`。 +- 一个 `dna-id` 只承载一种作品类型:抖音默认 dna-0 为视频,图文样本请另建 dna-id(如 dna-0-note)。 + +### 进 DNA 与不进 DNA + +| 进 DNA | 不进 DNA | +|--------|----------| +| 选题与观看理由、标题与封面写法、内容创意原型、业务植入套路、互动引导与 CTA 套路、视频内容形态与制作指向、制作规格与视听倾向、(口播类)口播文案子DNA、(对标账号)账号运营子模块 | 创作细节、脚本结构、逐句台词、镜头表、转场与编码参数——那些归 Content Producer | + +视频 DNA 的用途是指导 main agent 出具 **Brief.md** 与(口播类的)**口播文案**。template 就是 Brief 正文模板 + 口播文案模板(可选),不是成片制作模板。账号运营子模块(简介写法、内容形式比例、发布习惯)只写进 DNA 文档,**不进 template**。 + +## 维度(10 维) + +### 一、选题与包装 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 1 | `topic-angle` | 选题与观看理由 | 选题类型、选题入口(现象 / 问题 / 冲突 / 数据 / 热点 / 挑战 / 个人经历)、目标人群为什么要看完(理解 / 判断 / 行动 / 避坑 / 身份认同 / 情绪共鸣)、系列与内容支柱归属 | +| 2 | `title-cover` | 标题与封面 | 标题类型(痛点 / 数字 / 反差 / 悬念 / 身份点名 / 搜索长尾)、标题与描述原文、话题标签策略;封面或首帧的视觉特征与 AIGC 复现要素 | + +### 二、内容创意 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 3 | `content-idea` | 内容创意 | 一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点;这个套路换主题还能怎么用 | + +### 三、形态与规格 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 4 | `video-form` | 视频内容形态与制作指向 | 视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)+ 判定依据 + **制作指向**(见下方映射表) | +| 5 | `production-spec` | 制作规格与视听倾向 | 横屏或竖屏、时长带、画面风格(色调、质感、字幕样式倾向、信息密度)、配音音色与声音形态(原声口播 / TTS / 旁白 / 纯画面字幕)、BGM 与音效倾向、封面规格 | + +### 四、业务植入与转化 + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 6 | `biz-implant` | 业务植入套路 | 是否有业务植入(纯内容 / 软植入 / 硬广直给)、植入位置与时机(前 3 秒 / 中段转折 / 结尾收束 / 反转点 / 评论区置顶 / 主页与组件)、植入载体(剧情道具、口播一句话、字幕卡片、场景背景、案例与数据、测评对象、购物车或留资组件、私信与主页引导)、植入方式原型(反转植入 / 痛点→方案 / 场景带入 / 实测对比 / 清单第 N 项 / 身份认同 / 口碑故事 / 教程内嵌 / 硬广直给)、内容与业务的衔接句(转折触发词原文)、植入密度与占比(时长占比、品牌词与产品名出现次数与方式);形态层面的「影视解说 + 反转植入」在 `video-form` 记形态与制作指向,本维度记植入怎么设计 | +| 7 | `interaction-cta` | 互动引导与 CTA 套路 | 行动目标(关注 / 评论关键词 / 私信 / 主页点击 / 搜索品牌词 / 购物车 / 直播预约 / 转发)与本篇主目标、CTA 出现位置与时机(口播收尾句 / 字幕卡 / 片尾贴片 / 描述区 / 评论区置顶)、CTA 句式与原文摘录(命令式 / 提问式 / 利益式 / 身份式 / 悬念式)、一篇放几个行动、诱因设计(利益点 / 情绪 / 身份认同 / 稀缺)、与业务转化目标的对应、平台组件挂载与合规边界 | + +### 五、口播文案子模块(可选,仅口播类启用) + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 8 | `narration-script` | 口播文案子DNA | 起(从什么起步)、承(靠什么推进)、转(转折触发)、合(收束方式;CTA 的目标、位置与句式记在 `interaction-cta`)、人称与语气、句长与语速、签名式表达——用于指导 main agent 写同类型视频的口播文案 | + +### 六、账号运营子模块(对标账号样本才有) + +| # | ID | 维度 | 观测内容 | +|---|----|------|----------| +| 9 | `account-bio` | 账号简介写法 | 账号昵称、简介写法、主页与置顶表达、对外承诺(仅对标账号样本可得) | +| 10 | `content-mix-cadence` | 内容形式比例与发布习惯 | 图文 / 视频等内容形式比例、发布时间段与频率、内容形式混合节奏(如三篇图文对一篇视频)、栏目化节奏(仅对标账号批量样本可得) | + +## Report 与聚合规则 + +1. 单篇 report 先填「样本观测」:作品类型、样本来源、账号与简介、发布时间、数据线索、横竖屏与时长、素材来源与授权、转录与关键帧来源。缺失一律写「未观测」,不得编造。 +2. 单篇 report 不判断跨篇稳定性;聚合时区分高覆盖共性、高权重样本偏好、局部借鉴(focus)、孤例与例外,并标注样本覆盖度。 +3. 视觉维度(封面 / 首帧 / 图组)必须有图片证据,由视觉模型读取本地图片后反推 AIGC 复现要素;没有图片写「未提供」。 +4. 口播文案子模块只在口播类作品启用;非口播类或样本不足写「未启用 / 未观测」,不得把单篇句式上升为规则。 +5. 账号运营子模块(`account-bio`、`content-mix-cadence`)只在样本来自对标账号批量提取时填写;用户只提供单篇时写「未观测」。 +6. 对于口播类视频样本,脚本统计只作证据底座(句长、问句密度、人称密度、感叹号密度、口播密度),不生成总分、不判定风格是否合格。 +7. 业务植入与 CTA 两个维度必须给出**位置 + 载体 + 原文摘录**三样证据;只写「自然植入」「引导关注」这类空泛结论不算提取完成。本篇没有业务植入时明确写「无植入(纯内容)」,不得留空;平台红线(虚假承诺、利益诱导互动、隐藏站外联系方式、谐音绕检测)一律记为必须避免项。 +8. 高数据样本必须回读创意、形态与包装再归因,不得把高播放直接等同于风格好。 + +## Template 语义 + +DNA template = **Brief.md 正文模板 + 口播文案模板(可选)**,固定语义段如下(脚本 `build` / `update` 自动生成骨架,字段由 Agent 从 DNA 文档推导填写): + +1. **[选题部分]** — 选题角度推荐、选题需考虑的受众关联角度、内容支柱与系列关系、避免 +2. **[标题与封面部分]** — 标题类型、参考标题、封面或首帧风格、封面 AIGC 提示词要素、话题标签策略 +3. **[内容创意部分]** — 创意原型、展开逻辑、记忆点与反转设计、触发条件、避免 +4. **[业务植入与 CTA部分]** — 植入位置与时机、植入载体与方式原型、内容与业务的衔接句、植入密度与占比、CTA 主目标、CTA 位置与句式、诱因与合规红线、避免 +5. **[视频形态与制作指向部分]** — 视频内容形态、制作指向、委托边界、未指定形态时 +6. **[制作规格部分]** — 横屏或竖屏、时长带、画面风格、配音音色与声音形态、BGM 与音效、字幕 +7. **[口播文案部分]** — 是否启用、起、承、转、合、人称与语气、句长与语速、签名式表达、必须做、避免 + +- 开头两段(**选题**、**标题与封面**)跨平台通用:任何生产都先解决「做什么」和「怎么命名、怎么呈现封面」。 +- `[视频形态与制作指向部分]` 的「制作指向」必须写下方映射表里的真实资源名,Brief 的 `workflow` 字段据此填写。 +- 账号运营子模块不进 template:`account-bio` 与 `content-mix-cadence` 的结论只留在 DNA 文档,供起号、对标与发布节奏决策使用。 +- template 必须能从 DNA 文档推导,不得引入 DNA 文档未确认的规则;用户输入必须先映射到维度再转译为执行规则。 + +## 制作指向映射 + +`video-form` 维度提炼出的内容形态,必须能明确指向后续用什么技能制作: + +| DNA 观测到的内容形态 | 制作指向(只能写真实存在的资源名) | Brief `workflow` 字段值 | +|---------------------|-----------------------------------|------------------------| +| 影视解说 / 剧情解说 + 反转植入(「万万没想到」式) | Content Producer `expert-video` → **Reversal Ad** workflow | `reversal-ad` | +| 口播类(真人口播出镜,或旁白 + 画面) | Content Producer `expert-video` → **Narration Video** workflow | `narration-video` | +| 一句文稿转视觉隐喻的纸拼贴动画 | Content Producer `expert-video` → **Collage B-roll** workflow | `collage-broll` | +| 纯 AIGC 动画 / 剧情短片 / 蒙太奇拼接(需从零出脚本分镜) | Content Producer `expert-video` → **不指定类型 workflow**:CP 按其通用制作流程做,Stage 1 定档位(narrative / motion / montage) | 省略 | +| 已有素材简单拼接、加旁白、烧字幕 | main `video-edit`(不委托 CP) | — | +| 已有真人口播素材去口气词、剪高光 | main `talking-head-cut`(不委托 CP) | — | +| 产品操作录屏 | main `ui-demo`(不委托 CP) | — | + +Brief 写了 `workflow` 时 Content Producer 必须直接采用,不得替换成自创流程;未写时 CP 按其**通用制作流程**做——那是 CP 所有视频工作的基准准则,不是与其他 workflow 并列的选项,也不是 fallback,档位由 Stage 1 `intent-router` 定。 + +## Focus ID 表 + +`--focus` 只接受本框架的维度 ID;限制某篇样本只影响这些维度。 + +| 分组 | Focus ID | +|------|----------| +| 选题与包装 | `topic-angle` `title-cover` | +| 内容创意 | `content-idea` | +| 形态与规格 | `video-form` `production-spec` | +| 业务植入与转化 | `biz-implant` `interaction-cta` | +| 口播文案子模块(可选,仅口播类启用) | `narration-script` | +| 账号运营子模块(对标账号样本才有) | `account-bio` `content-mix-cadence` | + +focus 校验按作品类型执行:视频 report 不接受图文维度 ID,反之亦然。 diff --git a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py index a6919ea9..c9af3557 100644 --- a/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py +++ b/crews/main/skills/expert-douyin/tools/douyin-style-profiler/scripts/build_style_profile.py @@ -1,14 +1,22 @@ #!/usr/bin/env python3 +"""douyin-style-profiler:按作品类型(video / note)生成定性 DNA 资产。 + +三层产物(与 docs/expert-pack-dna-architecture.md 4.6 一致): + 单篇作品 -> DNA report + 同一 DNA 目录下全部 report + 权重/focus + 用户输入转译 -> DNA 文档 + DNA 文档 -> DNA template + +维度框架见本工具 references/ 下的 FRAMEWORK 文档(视频 / 图文各一份)。 +脚本只做 scaffold 与统计证据底座:不评分、不判定风格合格,定性结论由 Agent 回读原文补齐。 +""" import argparse, json, math, re, shutil -from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path -from statistics import median SENTENCE_SPLIT = re.compile(r"[。!?!?]+") +PARAGRAPH_SPLIT = re.compile(r"\n\s*\n") TOKEN_RE = re.compile(r"[一-鿿A-Za-z0-9_]+") -ENGLISH_WORD_RE = re.compile(r"[A-Za-z0-9_]+") SECOND_PERSON_RE = re.compile(r"你们?|you", re.IGNORECASE) FIRST_PERSON_RE = re.compile(r"我们?|I|we", re.IGNORECASE) QUESTION_RE = re.compile(r"[??]") @@ -16,113 +24,237 @@ ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,63}$") SOURCE_BLOCK_RE = re.compile(r"", re.DOTALL) REPORT_BLOCK_RE = re.compile(r"", re.DOTALL) +# 图文正文内联话题标签:#话题(样本文件约定正文为纯文本,行首 # 只出现在标题行) +TAG_RE = re.compile(r"(?m)(?:^|(?<=\s))#[^\s#]\S*") +EMOJI_RE = re.compile( + "[" + "\U0001F1E6-\U0001F1FF" + "\U0001F300-\U0001F5FF" + "\U0001F600-\U0001F64F" + "\U0001F680-\U0001F6FF" + "\U0001F700-\U0001F77F" + "\U0001F780-\U0001F7FF" + "\U0001F800-\U0001F8FF" + "\U0001F900-\U0001F9FF" + "\U0001FA00-\U0001FA6F" + "\U0001FA70-\U0001FAFF" + "☀-⛿" + "✀-➿" + "⬀-⯿" + "️" + "]" +) + +# ── CONFIG-BEGIN ── +PLATFORM = "douyin" + +PLATFORM_LABEL = "抖音" + +PLATFORM_DESC = "抖音短视频与图文作品" + +KINDS = ("video", "note") + +DEFAULT_KIND = "video" + +KIND_LABELS = {"video": "视频作品", "note": "图文作品"} + +KIND_SUFFIX_HINT = "抖音默认 dna-0 为视频,图文样本请另建 dna-id(如 dna-0-note)" + +FRAMEWORK_DOCS = {"video": "video-dna-framework.md", "note": "note-dna-framework.md"} + +# DNA 维度 v2(按作品类型分框架)。调整维度必须升版本,并同步 references/ 下的框架文档与工具 SKILL.md 的 Focus ID 表。 +DIMENSION_GROUPS = { + "video": { + "选题与包装": [("topic-angle", "选题与观看理由"), ("title-cover", "标题与封面")], + "内容创意": [("content-idea", "内容创意")], + "形态与规格": [("video-form", "视频内容形态与制作指向"), ("production-spec", "制作规格与视听倾向")], + "业务植入与转化": [("biz-implant", "业务植入套路"), ("interaction-cta", "互动引导与 CTA 套路")], + "口播文案子模块(可选,仅口播类启用)": [("narration-script", "口播文案子DNA")], + "账号运营子模块(对标账号样本才有)": [("account-bio", "账号简介写法"), ("content-mix-cadence", "内容形式比例与发布习惯")], + }, + "note": { + "选题与包装": [("topic-angle", "选题与观看理由"), ("title-cover", "标题与封面图组")], + "内容创意": [("content-idea", "内容创意")], + "正文与视觉": [("body-voice", "正文表达与语气"), ("imageset-visual", "图组视觉风格")], + "业务植入与转化": [("biz-implant", "业务植入套路"), ("interaction-cta", "互动引导与 CTA 套路")], + "账号运营子模块(对标账号样本才有)": [("account-bio", "账号简介写法"), ("content-mix-cadence", "内容形式比例与发布习惯")], + }, +} + +STATISTICS_METRICS = { + "video": [ + "avg_sentence_tokens", + "question_density_per_100_sentences", + "second_person_density_per_100_sentences", + "exclamation_density_per_1000_characters", + "speech_chars_per_minute", + ], + "note": [ + "title_chars", + "line_count", + "avg_sentence_tokens", + "question_density_per_100_sentences", + "second_person_density_per_100_sentences", + "emoji_density_per_100_characters", + "tag_count", + ], +} + +REPORT_DIMENSION_PROMPTS = { + "topic-angle": "- 单篇观测:选题类型、选题入口(现象 / 问题 / 冲突 / 数据 / 热点 / 挑战 / 个人经历)、目标人群为什么要看完(理解 / 判断 / 行动 / 避坑 / 身份认同 / 情绪共鸣)。\n- 边界:只记本篇,不判断跨篇稳定性;不评价选题好坏。", + "title-cover": "- 单篇观测:标题类型(痛点 / 数字 / 反差 / 悬念 / 身份点名 / 搜索长尾)、标题与描述原文、话题标签策略。\n- 视觉证据:封面或首帧必须由视觉模型读取图片,至少提取画面主体与场景、构图与画幅、色彩体系、光线与质感、风格与媒介、文字视觉与图文关系、品牌识别元素、避免项,并反推为可执行的 AIGC 提示词要素;无图片写「未提供」,不得凭正文或标题想象补齐。", + "content-idea": "- 单篇观测:一句话创意内核、创意类型、展开逻辑(悬念 / 反转 / 递进 / 对比 / 清单 / 实测)、记忆点。\n- 可复用信号:这个创意套路换成别的主题还能怎么用。\n- 边界:只记创意层,不记创作细节(逐句台词、镜头表、脚本结构、转场与编码参数)。", + "video-form": "- 单篇观测:视频内容形态(口播 / 实拍拼接 / 影视解说+反转植入 / 纯 AIGC 动画 / 创意转场动效 / 录屏演示 / 图文卡片视频 / 混合)与判定依据(画面证据、口播占比、素材来源)。\n- 制作指向:必须落到真实存在的资源名——Content Producer `expert-video` 的某个 workflow(Reversal Ad / Narration Video / Collage B-roll;不属这三类就写「不指定类型 workflow」,由 CP 按通用制作流程 + Stage 1 定档位),或 main 的素材加工技能(`video-edit` / `talking-head-cut` / `ui-demo`);不得发明不存在的名字。", + "production-spec": "- 单篇观测:横屏或竖屏、时长带、画面风格(色调、质感、字幕样式倾向、信息密度)、配音音色与声音形态(原声口播 / TTS / 旁白 / 纯画面字幕)、BGM 与音效倾向、封面规格。\n- 边界:只记规格与倾向,不规定镜头参数、逐镜设计、转场与编码细节——那些归 Content Producer。", + "narration-script": "- 子模块(仅口播类作品启用):起(从什么起步)、承(靠什么推进)、转(转折触发)、合(收束方式;CTA 的目标、位置与句式记在 `interaction-cta`),以及人称与语气、句长与语速、签名式表达。\n- 边界:非口播类或证据不足时写「未启用 / 未观测」;不得把单篇句式直接上升为规则。", + "body-voice": "- 单篇观测:开头钩子(原文摘录)、正文组织方式(清单体 / 教程步骤 / 故事线 / 对比 / 观点输出)、分行与段落节奏、口语化程度与人称、emoji 与标点用法、签名式表达。\n- 证据边界:脚本统计只给句长 / 行数 / emoji / 标签等线索;口头禅与签名表达必须回读原文确认。", + "imageset-visual": "- 单篇观测:图片数量与顺序、构图类型(产品展示 / 场景 / 文字卡片 / 对比图 / 过程图)、图文信息分工、色调与质感、版式一致性、文字视觉。\n- 视觉证据:必须由视觉模型读取本地图片并反推 AIGC 复现要素;无图片写「未提供」,不得凭正文想象补齐。", + "biz-implant": "- 单篇观测:是否有业务植入(纯内容 / 软植入 / 硬广直给)、植入位置与时机、植入载体(剧情道具 / 口播一句话 / 字幕卡片 / 场景背景 / 案例与数据 / 清单第 N 项 / 教程步骤内嵌 / 产品截图 / 购物车或留资组件 / 主页与私信引导)、植入方式原型(反转植入 / 痛点→方案 / 场景带入 / 实测对比 / 身份认同 / 口碑故事 / 教程内嵌 / 硬广直给)、内容与业务的衔接句(原文摘录)、植入密度与占比、品牌词与产品名出现方式与频次。\n- 证据要求:位置 + 载体 + 原文摘录三样齐全;本篇无植入时写「无植入(纯内容)」,不得留空。\n- 边界:只记植入套路,不写逐句广告文案;形态层面的「影视解说 + 反转植入」由 `video-form` 记形态与制作指向。", + "interaction-cta": "- 单篇观测:行动目标(关注 / 评论 / 收藏 / 转发 / 私信 / 主页点击 / 进店 / 咨询 / 搜索品牌词 / 购物车 / 直播预约)与本篇主目标、CTA 出现位置与时机(口播收尾句 / 字幕卡 / 片尾贴片 / 描述区 / 正文结尾 / 图组末图 / 评论区)、CTA 句式与原文摘录(命令式 / 提问式 / 利益式 / 身份式 / 悬念式)、一篇放几个行动、诱因设计(利益点 / 情绪 / 身份认同 / 稀缺)、与业务转化目标的对应。\n- 合规边界:不隐藏站外联系方式、不绕平台检测、不以利益换互动,记为必须避免项。", + "account-bio": "- 子模块(仅对标账号样本可得):账号昵称、简介写法、主页与置顶表达、对外承诺。\n- 边界:用户提供的单篇样本无法观测时写「未观测」,不得推导。", + "content-mix-cadence": "- 子模块(仅对标账号批量样本可得):图文 / 视频等内容形式比例、发布时间段与频率、内容形式混合节奏(如三篇图文对一篇视频)、栏目化节奏。\n- 边界:必须由账号发布列表的批量样本推导;单篇样本只记本篇发布时间。", +} + +REPORT_OBSERVATION_PROMPTS = { + "video": "- 作品类型:视频(本框架只用于视频作品;图文样本走 note-dna-framework)。\n- 样本来源:待 Agent 补齐(对标账号批量作品 / 用户提供的单篇或多篇 / 用户想法转译)。\n- 账号与简介:待 Agent 补齐;非账号样本写未观测。\n- 发布时间与时间段:待 Agent 补齐;单篇只记本篇时间,不推导账号节奏。\n- 数据线索:待 Agent 补齐(播放 / 点赞 / 评论 / 分享 / 收藏);只作证据,不直接判风格好坏。\n- 横竖屏与时长:待 Agent 补齐。\n- 素材来源与授权:待 Agent 补齐(实拍 / 影视或开源片源 / AIGC / 录屏 / 混合)。\n- 转录与关键帧来源:待 Agent 补齐(如 main 的 viral-chaser 产物路径);缺失写未提供,不得编造。", + "note": "- 作品类型:图文(本框架只用于图文作品;视频样本走 video-dna-framework)。\n- 样本来源:待 Agent 补齐(对标账号批量作品 / 用户提供的单篇或多篇 / 用户想法转译)。\n- 账号与简介:待 Agent 补齐;非账号样本写未观测。\n- 发布时间与时间段:待 Agent 补齐;单篇只记本篇时间,不推导账号节奏。\n- 数据线索:待 Agent 补齐(阅读 / 点赞 / 收藏 / 评论 / 分享);只作证据,不直接判风格好坏。\n- 图片数量与来源:待 Agent 补齐(封面 + 配图张数、实拍 / 截图 / AIGC)。\n- 关键词与标签:待 Agent 补齐(标题、正文与标签区原样记录)。", +} + +TEMPLATE_STAGES = { + "video": ("选题", "标题与封面", "内容创意", "业务植入与 CTA", "视频形态与制作指向", "制作规格", "口播文案"), + "note": ("选题", "标题与封面", "内容创意与结构", "正文表达", "图组", "业务植入与 CTA"), +} + +TEMPLATE_STAGE_FIELDS = { + "选题": ("选题角度推荐", "选题需考虑的受众关联角度", "内容支柱与系列关系", "避免"), + "标题与封面": ("标题类型", "参考标题", "封面或首帧风格", "封面 AIGC 提示词要素", "话题标签策略"), + "内容创意": ("创意原型", "展开逻辑", "记忆点与反转设计", "触发条件", "避免"), + "视频形态与制作指向": ("视频内容形态", "制作指向", "委托边界", "未指定形态时"), + "制作规格": ("横屏或竖屏", "时长带", "画面风格", "配音音色与声音形态", "BGM 与音效", "字幕"), + "口播文案": ("是否启用", "起", "承", "转", "合", "人称与语气", "句长与语速", "签名式表达", "必须做", "避免"), + "业务植入与 CTA": ("植入位置与时机", "植入载体与方式原型", "内容与业务的衔接句", "植入密度与占比", "CTA 主目标", "CTA 位置与句式", "诱因与合规红线", "避免"), + "内容创意与结构": ("创意原型", "正文组织方式", "信息密度", "记忆点", "避免"), + "正文表达": ("开头钩子", "推进方式", "段落与分行节奏", "人称与语气", "emoji 与标点", "签名式表达", "必须做", "避免"), + "图组": ("图片数量与顺序", "构图类型", "色调与质感", "版式一致性", "文字视觉", "AIGC 提示词要素"), +} + +TEMPLATE_INTROS = { + "video": "本模板是 main agent 出具**视频制作 Brief(brief.md)**与(口播类)**口播文案**的输入模板,必须由 DNA 文档推导,不得引入 DNA 文档未确认的规则。\n\n- 下列各段直接对应 Brief 的正文字段;Brief 的其余字段(素材清单与授权、验收标准、闸门批准人)由平台 Content Production Workflow 规定。\n- Brief **不含 DNA 信息**:Content Producer 看不到 main 的 DNA,只按 Brief 制作。\n- 模板不规定创作细节:逐句台词、镜头表、转场与编码参数归 Content Producer。\n- 账号运营子模块(简介写法、内容形式比例、发布习惯)写在 DNA 文档,不进本模板。", + "note": "本模板是 main agent 直接生产**图文作品**的写作模板,必须由 DNA 文档推导,不得引入 DNA 文档未确认的规则。\n\n- 开头两段(选题、标题与封面)跨平台通用。\n- 固定的是语义部分,不是物理段落数量:任一部分可对应一个或多个自然段,也可略过。\n- 每条规则必须能从 DNA 文档的聚合结论推导,避免「专业」「亲切」这类空泛形容词。\n- 账号运营子模块写在 DNA 文档,不进本模板。", +} + +TEMPLATE_CHECKLISTS = { + "video": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题 / 描述、封面是否来自 DNA 文档。\n- 内容创意是否落到可复用的创意原型,而不是照抄样本主题。\n- 视频形态是否明确指向 Content Producer `expert-video` 的某个 workflow,或 main 的某个素材加工技能。\n- Brief 是否只含制作所需信息(不含 DNA 内容),素材是否给了绝对路径与授权说明。\n- 口播类是否附口播文案(或真人口播录音路径);口播子模块未启用时是否避免规定逐句口播。\n- 制作规格(横竖屏、时长带、画面风格、配音音色)是否尊重样本覆盖度;样本不足时是否标注未观测。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「自然植入」),CTA 是否只有一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", + "note": "- 是否只用一个 DNA,且作品类型与该 DNA 的 `kind` 一致。\n- 选题、标题与封面图组是否来自 DNA 文档。\n- 正文表达的每条规则是否可从 DNA 文档推导,未使用空泛形容词。\n- 图组数量、构图与视觉风格是否与 DNA 一致;视觉结论是否有图片证据。\n- 业务植入是否落到位置 + 载体 + 衔接句(而不是只写「软性推荐」),CTA 是否每篇只放一个主行动、句式可执行且未越合规红线。\n- 用户输入是否已转译为具体执行规则。", +} + +DNA_SUBMODULE_NOTE = "- **口播文案子模块**(`narration-script`):仅口播类视频启用,用于指导 main agent 写同类型视频的口播文案;它不是独立 DNA,未启用时写「未启用」。\n- **账号运营子模块**(`account-bio`、`content-mix-cadence`):只在样本来自用户提供的对标账号(可从账号发布列表批量提取)时填写;结论只写进本 DNA 文档,不进 template;样本不足写「未观测」。" +# ── CONFIG-END ── -STATISTICS_METRICS = [ +STATISTICS_METRICS_ALL = [ + "title_chars", + "line_count", + "paragraphs", + "avg_paragraph_tokens", "avg_sentence_tokens", "question_density_per_100_sentences", "second_person_density_per_100_sentences", "first_person_density_per_100_sentences", "exclamation_density_per_1000_characters", "speech_chars_per_minute", + "emoji_density_per_100_characters", + "tag_count", ] -STOP_TERMS = { - "一个", "我们", "你们", "这里", "不会", "这个", "那个", "什么", "可以", "因为", - "但是", "所以", "还是", "以及", "如果", "他们", "自己", "的时候", "to", "the", - "a", "an", "is", "are", "and", "or", "of", "in", "for", "on", "with", "you", "we", +METRIC_LABELS = { + "title_chars": "标题字数", + "line_count": "正文行数", + "paragraphs": "段落数", + "avg_paragraph_tokens": "平均每段长度(token)", + "avg_sentence_tokens": "平均句长(token)", + "question_density_per_100_sentences": "问句密度 / 百句", + "second_person_density_per_100_sentences": "第二人称密度 / 百句", + "first_person_density_per_100_sentences": "第一人称密度 / 百句", + "exclamation_density_per_1000_characters": "感叹号密度 / 千字", + "speech_chars_per_minute": "口播密度(字/分钟)", + "emoji_density_per_100_characters": "emoji 密度 / 百字", + "tag_count": "话题标签数", } -DIMENSION_GROUPS = { - "选题与包装": [ - ("topic-angle", "选题角度"), - ("title-style", "标题与文案"), - ("cover-frame", "封面与首帧"), - ], - "钩子与表达": [ - ("hook", "3秒钩子"), - ("word-habit", "用词习惯"), - ("speech-rhythm", "口播节奏"), - ("tone", "语气与人设感"), - ], - "视觉与制作": [ - ("shot-language", "镜头语言"), - ("visual-style", "画面风格"), - ("sound-design", "声音与BGM"), - ], - "结构与节奏": [ - ("video-structure", "视频结构模式"), - ("narrative-rhythm", "叙事节奏"), - ("conflict-tension", "冲突与张力"), - ("professionalism", "专业度体现"), - ], - "互动与标记": [ - ("interaction-design", "互动设计"), - ("signature", "签名式标记"), - ("series-design", "系列化与合集"), - ], -} -DIMENSIONS = [] -number = 1 -for group, dimensions in DIMENSION_GROUPS.items(): - for dimension_id, name in dimensions: - DIMENSIONS.append( - {"id": dimension_id, "number": number, "name": name, "group": group} - ) - number += 1 + +def dimensions_for(kind: str) -> list[dict]: + """把某个作品类型的分组配置摊平成带序号的维度列表。""" + dimensions = [] + number = 1 + for group, items in DIMENSION_GROUPS[kind].items(): + for dimension_id, name in items: + dimensions.append({"id": dimension_id, "number": number, "name": name, "group": group}) + number += 1 + return dimensions + + +def statistics_metrics(kind: str) -> list[str]: + wanted = STATISTICS_METRICS[kind] + return [metric for metric in STATISTICS_METRICS_ALL if metric in wanted] def split_sentences(text: str) -> list[str]: return [item.strip() for item in SENTENCE_SPLIT.split(text) if item.strip()] + +def split_paragraphs(text: str) -> list[str]: + return [item.strip() for item in PARAGRAPH_SPLIT.split(text) if item.strip()] + + def tokenize(text: str) -> list[str]: return TOKEN_RE.findall(text) + def rounded(value: float) -> float: return round(value, 4) -def safe_ratio(numerator: int, denominator: int, multiplier: float = 1) -> float: + +def safe_ratio(numerator: int, denominator: float, multiplier: float = 1) -> float: return rounded((numerator / denominator) * multiplier) if denominator else 0.0 + def average(values: list[float]) -> float: return rounded(sum(values) / len(values)) if values else 0.0 -def extract_terms(text: str) -> Counter: - terms: Counter = Counter() - for token in tokenize(text): - if re.fullmatch(r"[A-Za-z0-9_]+", token): - term = token.lower() - if term not in STOP_TERMS and len(term) > 1: - terms[term] += 1 - continue - cleaned = "".join(ENGLISH_WORD_RE.sub("", token).split()) - if len(cleaned) == 1: +def split_title_body(text: str) -> tuple[str, str]: + """样本文件约定:首个一级标题行为作品标题,其余为正文(图文正文可含内联 #话题)。""" + title = "" + body_lines = [] + for line in text.splitlines(): + stripped = line.strip() + if not title and stripped.startswith("# ") and len(stripped) > 2: + title = stripped[2:].strip() continue - for start in range(len(cleaned) - 1): - term = cleaned[start : start + 2] - if term not in STOP_TERMS: - terms[term] += 1 - return terms + body_lines.append(line) + return title, "\n".join(body_lines).strip() def title_candidates(path: Path, text: str) -> list[str]: candidates = [path.stem] - for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("# ") and len(stripped) > 2: - candidates.append(stripped[2:].strip()) - break + title, _ = split_title_body(text) + if title: + candidates.append(title) return candidates def document_metrics(path: Path, text: str, duration: float = 0.0) -> dict: + """统计证据底座:视频与图文共用一套超集,展示哪些指标由 STATISTICS_METRICS[kind] 决定。""" + title, body = split_title_body(text) sentences = split_sentences(text) sentence_lengths = [len(tokenize(sentence)) for sentence in sentences] sentence_count = len(sentences) character_count = len(text) - speech_chars_per_minute = ( - rounded(character_count / duration * 60) if duration > 0 else 0.0 - ) + body_lines = [line for line in body.splitlines() if line.strip()] + paragraphs = split_paragraphs(body) + paragraph_lengths = [len(tokenize(paragraph)) for paragraph in paragraphs] + emoji_count = len(EMOJI_RE.findall(body)) + speech_chars_per_minute = rounded(character_count / duration * 60) if duration > 0 else 0.0 return { "source_transcript": str(path.resolve()), @@ -130,21 +262,19 @@ def document_metrics(path: Path, text: str, duration: float = 0.0) -> dict: "characters": character_count, "sentences": sentence_count, "duration": duration, + "title_chars": len(title), + "line_count": len(body_lines), + "paragraphs": len(paragraphs), + "avg_paragraph_tokens": average([float(value) for value in paragraph_lengths]), "avg_sentence_tokens": average([float(value) for value in sentence_lengths]), - "question_density_per_100_sentences": safe_ratio( - len(QUESTION_RE.findall(text)), sentence_count, 100 - ), - "second_person_density_per_100_sentences": safe_ratio( - len(SECOND_PERSON_RE.findall(text)), sentence_count, 100 - ), - "first_person_density_per_100_sentences": safe_ratio( - len(FIRST_PERSON_RE.findall(text)), sentence_count, 100 - ), - "exclamation_density_per_1000_characters": safe_ratio( - len(EXCLAMATION_RE.findall(text)), character_count, 1000 - ), + "question_density_per_100_sentences": safe_ratio(len(QUESTION_RE.findall(text)), sentence_count, 100), + "second_person_density_per_100_sentences": safe_ratio(len(SECOND_PERSON_RE.findall(text)), sentence_count, 100), + "first_person_density_per_100_sentences": safe_ratio(len(FIRST_PERSON_RE.findall(text)), sentence_count, 100), + "exclamation_density_per_1000_characters": safe_ratio(len(EXCLAMATION_RE.findall(text)), character_count, 1000), "speech_chars_per_minute": speech_chars_per_minute, - "terms": dict(extract_terms(text)), + "emoji_count": emoji_count, + "emoji_density_per_100_characters": safe_ratio(emoji_count, character_count, 100), + "tag_count": len(TAG_RE.findall(body)), } @@ -194,15 +324,7 @@ def generated_at() -> str: def metric_label(metric_name: str) -> str: - labels = { - "avg_sentence_tokens": "平均口播句长(token)", - "question_density_per_100_sentences": "问句密度 / 百句", - "second_person_density_per_100_sentences": "第二人称密度 / 百句", - "first_person_density_per_100_sentences": "第一人称密度 / 百句", - "exclamation_density_per_1000_characters": "感叹号密度 / 千字", - "speech_chars_per_minute": "口播密度(字/分钟)", - } - return labels.get(metric_name, metric_name) + return METRIC_LABELS.get(metric_name, metric_name) def weighted_median(pairs: list[tuple[float, float]]) -> float: @@ -225,10 +347,10 @@ def weighted_mad(pairs: list[tuple[float, float]], center: float) -> float: return weighted_median(deviations) -def build_statistics(reports: list[dict]) -> dict: +def build_statistics(reports: list[dict], kind: str) -> dict: total_weight = rounded(sum(report["weight"] for report in reports)) numeric_metrics = {} - for metric_name in STATISTICS_METRICS: + for metric_name in statistics_metrics(kind): pairs = [ (float(report["document"][metric_name]), float(report["weight"])) for report in reports @@ -241,50 +363,16 @@ def build_statistics(reports: list[dict]) -> dict: "max": rounded(max(value for value, _ in pairs)) if pairs else 0.0, } - term_weights: dict[str, float] = defaultdict(float) - term_counts: dict[str, list[int]] = defaultdict(list) - for report in reports: - for term, count in report["document"]["terms"].items(): - term_weights[term] += report["weight"] - term_counts[term].append(count) - stable_terms = [] - for term, coverage_weight in term_weights.items(): - stable_terms.append( - { - "term": term, - "weighted_coverage": rounded(coverage_weight / total_weight), - "report_count": len(term_counts[term]), - "median_count_per_report": rounded(median(term_counts[term])), - } - ) - stable_terms.sort( - key=lambda item: (item["weighted_coverage"], item["report_count"], item["term"]), - reverse=True, - ) - weights = [report["weight"] for report in reports] return { "report_count": len(reports), "total_weight": total_weight, "weighting": "user-specified" if any(abs(weight - 1) > 1e-9 for weight in weights) else "uniform", "numeric_metrics": numeric_metrics, - "stable_terms": stable_terms[:30], - "weighted_coverage": sorted( - ( - { - "report_id": report["report_id"], - "weight": report["weight"], - "focus": report["focus"], - } - for report in reports - ), - key=lambda item: item["weight"], - reverse=True, - ), } -def statistics_markdown(statistics: dict) -> str: +def statistics_markdown(statistics: dict, kind: str) -> str: lines = [ "| 指标 | 加权中位数 | 加权 MAD | 最小值 | 最大值 |", "|---|---:|---:|---:|---:|", @@ -296,9 +384,16 @@ def statistics_markdown(statistics: dict) -> str: lines.extend( [ "", + f"样本覆盖度:`{statistics['report_count']}` 条 {KIND_LABELS[kind]} report。", f"权重模式:`{statistics['weighting']}`;总权重:`{statistics['total_weight']}`。", - "口播密度(字/分钟)仅在 report 提供 `duration` 时有意义;未提供时该行只是 0 值占位。", - "统计只用于辅助聚合,不生成评分;定性判断必须回到各篇 DNA report。", + ] + ) + if kind == "video": + lines.append("口播密度(字/分钟)仅在 report 提供 `duration` 时有意义;未提供时该行只是 0 值占位。") + lines.extend( + [ + "「账号运营子模块」维度(简介写法、内容形式比例、发布习惯)不能由单篇样本推导:样本非对标账号批量时写未观测。", + "统计只用于辅助聚合,不生成评分;定性判断必须回到各篇 DNA report 与原文。", ] ) return "\n".join(lines) @@ -366,6 +461,7 @@ def load_reports(paths: list[Path]) -> list[dict]: "report_path": str(path.resolve()), "dna_id": parse_quoted(metadata.get("dna-id", "")), "report_id": parse_quoted(metadata.get("report-id", path.name.removesuffix(".report.md"))), + "kind": parse_quoted(metadata.get("kind", DEFAULT_KIND)), "title": parse_quoted(metadata.get("title", document["title_candidates"][-1])), "weight": float(metadata.get("weight", "1")), "focus": parse_json_list(metadata.get("focus", "[]")), @@ -375,58 +471,78 @@ def load_reports(paths: list[Path]) -> list[dict]: return reports -def report_dimension_markdown(dimension: dict) -> str: - heading = f"### {dimension['number']}. {dimension['name']}" - if dimension["id"] == "cover-frame": - return ( - f"{heading}\n\n" - "**视觉模型分析:**待 Agent 基于封面 / 首帧图补齐。\n\n" - "- 画面主体与场景:待 Agent 补齐。\n" - "- 构图与画幅:待 Agent 补齐。\n" - "- 色彩体系:待 Agent 补齐。\n" - "- 光线与质感:待 Agent 补齐。\n" - "- 风格与媒介:待 Agent 补齐。\n" - "- 文字视觉与图文关系(封面字 / 贴纸 / 标题框):待 Agent 补齐。\n" - "- 品牌识别元素:待 Agent 补齐。\n" - "- 避免项:待 Agent 补齐。\n\n" - "**AIGC 复现提示词要素:**待 Agent 补齐;要求能据此生成风格高度一致的封面图。\n\n" - "**可复用创作信号:**待 Agent 补齐。" +def enforce_single_kind(reports: list[dict], kind: str | None) -> str: + """一个 DNA 只承载一种作品类型:返回该 DNA 的 kind,混型直接报错。""" + kinds = {report["kind"] for report in reports} + if len(kinds) > 1: + raise SystemExit( + "Reports mix work kinds: " + + ", ".join(sorted(kinds)) + + f"。一个 dna-id 只能承载一种作品类型:{KIND_SUFFIX_HINT}。" ) - if dimension["id"] == "hook": - return ( - f"{heading}\n\n" - "**钩子原文(前 3 秒口播 / 首帧字幕):**待 Agent 逐字摘录。\n\n" - "**单条结论:**待 Agent 补齐(钩子类型、是否对应定位、正文是否兑现)。\n\n" - "**原文证据:**待 Agent 补齐。\n\n" - "**可复用创作信号:**待 Agent 补齐。" + resolved = kinds.pop() if kinds else (kind or DEFAULT_KIND) + if kind and resolved != kind: + raise SystemExit( + f"--kind {kind} 与 report 内的 kind {resolved} 不一致;一个 dna-id 只能承载一种作品类型。" ) + if resolved not in KINDS: + raise SystemExit(f"Unknown kind: {resolved}({PLATFORM_LABEL}支持:{', '.join(KINDS)})") + return resolved + + +def report_dimension_markdown(dimension: dict) -> str: + heading = f"### {dimension['number']}. {dimension['name']}" + prompt = REPORT_DIMENSION_PROMPTS.get(dimension["id"], "- 单篇观测:待 Agent 补齐。") return ( f"{heading}\n\n" - "**单条结论:**待 Agent 补齐。\n\n" - "**原文证据:**待 Agent 补齐(口播逐字引用或画面描述,注明时间区间)。\n\n" - "**可复用创作信号:**待 Agent 补齐。" + f"{prompt}\n\n" + "**单篇结论:**待 Agent 补齐。\n\n" + "**原文证据:**待 Agent 补齐(逐字引用、账号/发布信息、画面或声音描述、数据线索;注明来源)。\n\n" + "**可复用信号:**待 Agent 补齐;证据不足时写未观测,不推导跨篇稳定性。" ) def report_markdown( dna_id: str, report_id: str, + kind: str, weight: float, focus: list[str], document: dict, cover_image: str, source_url: str, ) -> str: - dimensions = [] - for dimension in DIMENSIONS: - dimensions.append(report_dimension_markdown(dimension)) + dimensions = dimensions_for(kind) + dimension_blocks = [report_dimension_markdown(dimension) for dimension in dimensions] duration_line = f"duration: {document['duration']}" + statistics_lines = [ + f"- 转录/正文字符:{document['characters']}", + f"- 句子数:{document['sentences']}", + ] + if kind == "video": + statistics_lines.append(f"- 视频时长:{document['duration'] or '未提供'}") + else: + statistics_lines.extend( + [ + f"- 标题字数:{document['title_chars']}", + f"- 正文行数:{document['line_count']}", + f"- 话题标签数:{document['tag_count']}", + ] + ) + statistics_lines.extend( + [ + f"- 标题候选:{' / '.join(document['title_candidates'])}", + f"- 来源链接:{source_url or '未提供'}", + f"- 封面 / 首帧图:{cover_image or '未提供'}", + ] + ) return "\n\n".join( [ "---\n" f"dna-id: {yaml_value(dna_id)}\n" f"report-id: {yaml_value(report_id)}\n" "type: dna-report\n" + f"kind: {yaml_value(kind)}\n" f"title: {yaml_value(document['title_candidates'][-1])}\n" f"source-transcript: {yaml_value(document['source_transcript'])}\n" f"source-url: {yaml_value(source_url)}\n" @@ -437,29 +553,34 @@ def report_markdown( "sample_count: 1\n" f"generated_at: {yaml_value(generated_at())}\n" "---", - f"# {document['title_candidates'][-1]} 单条 DNA Report", - "本文件只描述这一条视频。它不是聚合后的 DNA 文档,也不直接作为生产模板。", - "## 单条统计", - f"- 转录字符:{document['characters']}\n- 口播句子:{document['sentences']}\n- 视频时长:{document['duration'] or '未提供'}\n- 标题候选:{' / '.join(document['title_candidates'])}\n- 来源链接:{source_url or '未提供'}\n- 封面 / 首帧图:{cover_image or '未提供'}", - f"## {len(DIMENSIONS)} 维单条分析", - "\n\n".join(dimensions), - "## 单条边界", - "- 这里记录本条视频的可复用信号,不判断跨条稳定性。\n- 聚合时由 Agent 根据 DNA report、权重和 focus 判断共性、偏好和例外。", + f"# {document['title_candidates'][-1]} 单篇 DNA Report({KIND_LABELS[kind]})", + "本文件只描述这一篇作品。它不是聚合后的 DNA 文档,也不直接作为生产模板。", + "## 单篇统计", + "\n".join(statistics_lines), + "## 样本观测", + REPORT_OBSERVATION_PROMPTS[kind], + f"## {len(dimensions)} 维单篇分析", + "\n\n".join(dimension_blocks), + "## 单篇边界", + "- 这里记录本篇作品的可复用信号,不判断跨篇稳定性。\n" + "- 聚合时由 Agent 根据全部 DNA report、权重和 focus 判断共性、偏好和例外。\n" + f"- 维度定义与边界见本工具 `references/{FRAMEWORK_DOCS[kind]}`。", f"", - *( [f""] if cover_image else [] ), + *([f""] if cover_image else []), ] ) + "\n" -def user_input_markdown(user_inputs: list[str], existing_body: str | None = None) -> str: +def user_input_markdown(user_inputs: list[str], kind: str, existing_body: str | None = None) -> str: if not user_inputs: return existing_body or "暂无待转译输入。" + dimension_count = len(dimensions_for(kind)) entries = [] for index, user_input in enumerate(user_inputs, start=1): entries.append( f"### 输入 {index}\n" f"- raw_input: {yaml_value(user_input)}\n" - f"- affected_dimensions: 待 Agent 映射到 {len(DIMENSIONS)} 个维度 ID\n" + f"- affected_dimensions: 待 Agent 映射到 {dimension_count} 个维度 ID\n" "- dna_document_change: 待 Agent 转译为聚合结论 / 报告依据 / 创作规则\n" "- template_change: 待 Agent 转译为具体执行规则\n" "- status: pending" @@ -471,32 +592,26 @@ def user_input_markdown(user_inputs: list[str], existing_body: str | None = None def dna_document_markdown( dna_id: str, + kind: str, reports: list[dict], statistics: dict, user_inputs: list[str] | None = None, previous_dna: str | None = None, ) -> str: + dimensions = dimensions_for(kind) old_sections = extract_markdown_sections(previous_dna, "### ") - dimensions = [] - for dimension in DIMENSIONS: + dimension_blocks = [] + for dimension in dimensions: heading = f"### {dimension['number']}. {dimension['name']}" body = old_sections.get(heading) if body: - dimensions.append(f"{heading}\n\n{body}") + dimension_blocks.append(f"{heading}\n\n{body}") else: - if dimension["id"] == "cover-frame": - dimensions.append( - f"{heading}\n\n**聚合结论:**待 Agent 补齐。\n\n" - "**报告依据:**待 Agent 列出使用的封面 / 首帧图和 DNA report。\n\n" - "**视觉生成规则:**待 Agent 转成可执行的 AIGC 提示词要素。\n\n" - "**例外与约束:**待 Agent 补齐。" - ) - else: - dimensions.append( - f"{heading}\n\n**聚合结论:**待 Agent 补齐。\n\n" - "**报告依据:**待 Agent 列出使用的 DNA report、权重和 focus。\n\n" - "**创作规则:**待 Agent 补齐。" - ) + dimension_blocks.append( + f"{heading}\n\n**聚合结论:**待 Agent 补齐。\n\n" + "**报告依据:**待 Agent 列出使用的 DNA report、权重和 focus。\n\n" + "**创作规则:**待 Agent 补齐。" + ) report_paths = "\n".join(report["report_path"] for report in reports) existing_user_inputs = ( extract_named_section(previous_dna, "## 用户输入转译区") if previous_dna else None @@ -506,25 +621,33 @@ def dna_document_markdown( "---\n" f"dna-id: {yaml_value(dna_id)}\n" "type: dna-document\n" + f"kind: {yaml_value(kind)}\n" + f"platform: {yaml_value(PLATFORM)}\n" f"report_count: {statistics['report_count']}\n" f"total_weight: {statistics['total_weight']}\n" f"weighting: {statistics['weighting']}\n" f"generated_at: {yaml_value(generated_at())}\n" "---", - f"# {dna_id} DNA 文档", - "本文件聚合历史 DNA report。它是账号当前采用的短视频内容与风格规则,也必须能推导出生产模板。", + f"# {dna_id} DNA 文档({PLATFORM_LABEL} · {KIND_LABELS[kind]})", + "本文件聚合该 DNA 目录下的全部 DNA report,形成当前采用的内容生产规则,并且必须能推导出 DNA template。" + "样本可以来自多个账号,也可以来自用户指定的一个账号的批量作品。", "## 报告与权重", "\n".join( f"- `{report['report_path']}`:weight `{report['weight']}`,focus `{', '.join(report['focus']) or 'all'}`" for report in reports ), - statistics_markdown(statistics), - f"## {len(DIMENSIONS)} 维聚合", - "\n\n".join(dimensions), + statistics_markdown(statistics, kind), + f"## {len(dimensions)} 维聚合", + "\n\n".join(dimension_blocks), + "## 子模块说明", + DNA_SUBMODULE_NOTE, "## 用户输入转译区", - user_input_markdown(user_inputs or [], existing_user_inputs), + user_input_markdown(user_inputs or [], kind, existing_user_inputs), "## 推导规则", - "- 聚合结论必须能追溯到 DNA report。\n- 用户输入必须先映射到具体维度,再修改聚合结论和创作规则;不得把原话直接当成 DNA 规则。\n- 模板必须由本文件推导,不能引入本文件未确认的规则。", + "- 聚合结论必须能追溯到 DNA report;区分高覆盖共性、高权重偏好、局部借鉴、孤例与例外。\n" + "- 用户输入必须先映射到具体维度,再修改聚合结论和创作规则;不得把原话直接当成 DNA 规则。\n" + "- template 必须由本文件推导,不能引入本文件未确认的规则。\n" + "- 账号运营子模块(简介写法、内容形式比例、发布习惯)只写进本文件,不进 template。", f"", ] ) + "\n" @@ -574,56 +697,6 @@ def extract_named_section(markdown: str, heading: str) -> str: return "\n".join(body).strip() -TEMPLATE_STAGES = ("起", "承", "转", "合", "CTA") - -TEMPLATE_STAGE_FIELDS = { - "起": ( - "本段任务", - "钩子类型", - "开场口播", - "画面呈现", - "句式节奏", - "必须做", - "避免", - ), - "承": ( - "本段任务", - "推进逻辑", - "信息密度", - "镜头与画面", - "口播表达", - "证据与素材", - "必须做", - "避免", - ), - "转": ( - "本段任务", - "转折触发", - "冲突与反差", - "情绪表达", - "句式节奏", - "必须做", - "避免", - ), - "合": ( - "本段任务", - "收束方式", - "情绪落点", - "签名式标记", - "必须做", - "避免", - ), - "CTA": ( - "行动目标", - "表达方式", - "时机与位置", - "受众关联", - "必须做", - "避免", - ), -} - - def parse_template_fields(body: str) -> dict[str, str]: fields = {} for line in body.splitlines(): @@ -633,8 +706,8 @@ def parse_template_fields(body: str) -> dict[str, str]: return fields -def template_stage_from_heading(heading: str) -> str | None: - for stage in TEMPLATE_STAGES: +def template_stage_from_heading(heading: str, kind: str) -> str | None: + for stage in TEMPLATE_STAGES[kind]: if heading.startswith(f"[{stage}部分]"): return stage return None @@ -649,29 +722,10 @@ def template_segment(stage: str, values: dict[str, str] | None = None) -> str: return "\n".join(lines) -def extract_topic_title_body(previous_template: str | None) -> str: - if not previous_template: - return "" - for heading in ("## 生产模板", "## 选题与标题"): - body = extract_named_section(previous_template, heading) - if not body: - continue - topic_title_lines = [] - for line in body.splitlines(): - # 只在真正的模板分段([起部分] 等)处截断;[标题] 属于选题与标题区,必须保留。 - if re.match(r"^\[[^\]]+部分\]$", line.strip()): - break - topic_title_lines.append(line) - rendered = "\n".join(topic_title_lines).strip() - if rendered: - return rendered - return "" - - -def stage_values_from_template(old_sections: dict[str, str]) -> dict[str, dict[str, str]]: - values = {stage: {} for stage in TEMPLATE_STAGES} - for heading in sorted(old_sections, key=template_order): - stage = template_stage_from_heading(heading) +def stage_values_from_template(old_sections: dict[str, str], kind: str) -> dict[str, dict[str, str]]: + values = {stage: {} for stage in TEMPLATE_STAGES[kind]} + for heading in sorted(old_sections, key=lambda item: template_order(item, kind)): + stage = template_stage_from_heading(heading, kind) if not stage: continue for field, value in parse_template_fields(old_sections[heading]).items(): @@ -681,44 +735,23 @@ def stage_values_from_template(old_sections: dict[str, str]) -> dict[str, dict[s def template_markdown( dna_id: str, + kind: str, source_dna: str, previous_template: str | None = None, ) -> str: - old_sections = extract_template_sections(previous_template) - stage_values = stage_values_from_template(old_sections) - segments = [template_segment(stage, stage_values[stage]) for stage in TEMPLATE_STAGES] - topic_title = extract_topic_title_body(previous_template) or ( - "(选题角度推荐:待 Agent 补齐。)\n" - "(选题需考虑的受众关联角度:待 Agent 补齐。)\n" - "\n" - "[标题](类型为主:待 Agent 补齐。)\n" - "(参考:待 Agent 补齐。)\n" - "(话题标签策略:待 Agent 补齐。)\n" - "(封面 / 首帧风格:待 Agent 补齐。)\n" - "(封面 AIGC 提示词要素:待 Agent 补齐。)" - ) - if previous_template: - for field in ("(封面 / 首帧风格:", "(封面 AIGC 提示词要素:"): - if field not in topic_title: - topic_title += f"\n{field}待 Agent 补齐。)" + stages = TEMPLATE_STAGES[kind] + old_sections = extract_template_sections(previous_template, kind) + stage_values = stage_values_from_template(old_sections, kind) + segments = [template_segment(stage, stage_values[stage]) for stage in stages] section_defaults = [ - ( - "## 生产模板", - topic_title + "\n\n" + "\n\n".join(segments), - ), + ("## 生产模板", "\n\n".join(segments)), ( "## 用户输入转译后的执行规则", "- (来自用户输入:待 Agent 补齐来源。)\n" - f"- (影响维度:待 Agent 映射到 {len(DIMENSIONS)} 维 ID。)\n" + f"- (影响维度:待 Agent 映射到 {len(dimensions_for(kind))} 维 ID。)\n" "- (执行规则:待 Agent 写成生产时可直接执行的要求。)", ), - ( - "## 使用检查", - "- 选题与标题是否符合 DNA 文档的选题角度、受众关联和标题类型。\n" - "- 起、承、转、合、CTA 五个部分是否完成各自任务。\n" - "- 每个部分是否反映 DNA 文档中对应的钩子、口播、镜头、节奏、冲突、互动与签名标记。\n" - "- 用户输入是否已转译为具体执行规则。", - ), + ("## 使用检查", TEMPLATE_CHECKLISTS[kind]), ] rendered_sections = [] for heading, default_body in section_defaults: @@ -736,17 +769,18 @@ def template_markdown( "---\n" f"dna-id: {yaml_value(dna_id)}\n" "type: dna-template\n" + f"kind: {yaml_value(kind)}\n" f"source_dna: {yaml_value(source_dna)}\n" f"generated_at: {yaml_value(generated_at())}\n" "---", - f"# {dna_id} DNA Template", - "本模板是视频生产时直接执行的 production template,必须由 DNA 文档推导;不得引入 DNA 文档未确认的规则。", + f"# {dna_id} DNA Template({PLATFORM_LABEL} · {KIND_LABELS[kind]})", + TEMPLATE_INTROS[kind], *rendered_sections, ] ) + "\n" -def extract_template_sections(markdown: str | None) -> dict[str, str]: +def extract_template_sections(markdown: str | None, kind: str) -> dict[str, str]: if not markdown: return {} sections = {} @@ -773,10 +807,10 @@ def extract_template_sections(markdown: str | None) -> dict[str, str]: return sections -def template_order(heading: str) -> tuple[int, str]: - stage = template_stage_from_heading(heading) +def template_order(heading: str, kind: str) -> tuple[int, str]: + stage = template_stage_from_heading(heading, kind) if stage: - return (TEMPLATE_STAGES.index(stage) + 1, heading) + return (TEMPLATE_STAGES[kind].index(stage) + 1, heading) return (10_000, heading) @@ -785,11 +819,19 @@ def validate_id(value: str, label: str) -> None: raise SystemExit(f"{label} must be 2-64 chars: lowercase letters, digits, and hyphens") -def validate_focus(focus: list[str]) -> None: - valid = {dimension["id"] for dimension in DIMENSIONS} +def validate_kind(value: str | None) -> str | None: + if value is None: + return None + if value not in KINDS: + raise SystemExit(f"Unknown --kind: {value}({PLATFORM_LABEL}支持:{', '.join(KINDS)})") + return value + + +def validate_focus(focus: list[str], kind: str) -> None: + valid = {dimension["id"] for dimension in dimensions_for(kind)} unknown = sorted(set(focus) - valid) if unknown: - raise SystemExit(f"Unknown focus: {', '.join(unknown)}") + raise SystemExit(f"Unknown focus for kind '{kind}': {', '.join(unknown)}") def validate_duration(value: str) -> float: @@ -816,38 +858,39 @@ def inputs_from_args(args: argparse.Namespace) -> list[str]: def report_command(args: argparse.Namespace) -> None: validate_id(args.dna_id, "--dna-id") validate_id(args.sample_id, "--sample-id") - validate_focus(args.focus) + kind = validate_kind(args.kind) or DEFAULT_KIND + validate_focus(args.focus, kind) weight = float(args.weight) if weight <= 0 or not math.isfinite(weight): raise SystemExit("--weight must be a positive finite number") duration = validate_duration(args.duration) if args.duration else 0.0 + if kind != "video" and duration: + print(f"[warn] --duration 只对视频作品有意义,{KIND_LABELS[kind]} report 忽略该值。") + duration = 0.0 paths = collect_input_paths(inputs_from_args(args), {".md", ".txt"}) if len(paths) != 1: raise SystemExit("report command accepts exactly one transcript; use build to aggregate reports") - document = document_metrics( - paths[0], paths[0].read_text(encoding="utf-8", errors="ignore"), duration - ) - output_dir = Path(args.output_dir or f"douyin/dna/{args.dna_id}/reports") + document = document_metrics(paths[0], paths[0].read_text(encoding="utf-8", errors="ignore"), duration) + output_dir = Path(args.output_dir or f"{PLATFORM}/dna/{args.dna_id}/reports") cover_image = "" if args.cover_image: source_cover = validate_cover_image(args.cover_image) - cover_image = str( - persist_cover_image(source_cover, output_dir, args.sample_id).resolve() - ) + cover_image = str(persist_cover_image(source_cover, output_dir, args.sample_id).resolve()) output = output_dir / f"{args.sample_id}.report.md" write_text( output, report_markdown( - args.dna_id, args.sample_id, weight, args.focus, document, cover_image, + args.dna_id, args.sample_id, kind, weight, args.focus, document, cover_image, args.source_url or "", ), ) - print(f"Wrote single-video DNA report: {output}") + print(f"Wrote single-work DNA report ({KIND_LABELS[kind]}): {output}") + print(f"[next] Agent 补齐「样本观测」与 {len(dimensions_for(kind))} 维单篇结论后,跑 build 聚合。") def build_command(args: argparse.Namespace) -> None: validate_id(args.dna_id, "--dna-id") - input_values = args.input or [f"douyin/dna/{args.dna_id}/reports"] + input_values = args.input or [f"{PLATFORM}/dna/{args.dna_id}/reports"] paths = collect_input_paths(input_values, {".md"}) report_paths = [path for path in paths if path.name.endswith(".report.md")] if not report_paths: @@ -855,22 +898,17 @@ def build_command(args: argparse.Namespace) -> None: reports = load_reports(report_paths) foreign_reports = [report["report_id"] for report in reports if report["dna_id"] != args.dna_id] if foreign_reports: - raise SystemExit( - f"Reports belong to another dna-id: {', '.join(foreign_reports)}" - ) - statistics = build_statistics(reports) - output_dir = Path(args.output_dir or f"douyin/dna/{args.dna_id}") + raise SystemExit(f"Reports belong to another dna-id: {', '.join(foreign_reports)}") + kind = enforce_single_kind(reports, validate_kind(args.kind)) + for report in reports: + validate_focus(report["focus"], kind) + statistics = build_statistics(reports, kind) + output_dir = Path(args.output_dir or f"{PLATFORM}/dna/{args.dna_id}") dna_path = output_dir / f"{args.dna_id}.dna.md" template_path = output_dir / f"{args.dna_id}.template.md" - write_text( - dna_path, - dna_document_markdown(args.dna_id, reports, statistics, args.user_input), - ) - write_text( - template_path, - template_markdown(args.dna_id, dna_path.name, None), - ) - print(f"Wrote DNA document and template: {output_dir}") + write_text(dna_path, dna_document_markdown(args.dna_id, kind, reports, statistics, args.user_input)) + write_text(template_path, template_markdown(args.dna_id, kind, dna_path.name, None)) + print(f"Wrote DNA document and template ({KIND_LABELS[kind]}): {output_dir}") def update_command(args: argparse.Namespace) -> None: @@ -883,7 +921,8 @@ def update_command(args: argparse.Namespace) -> None: metadata = parse_frontmatter(previous_dna) dna_id = parse_quoted(metadata.get("dna-id", dna_path.name.removesuffix(".dna.md"))) validate_id(dna_id, "--dna-id") - validate_focus(args.focus) + declared_kind = parse_quoted(metadata.get("kind", "")) or None + kind = validate_kind(args.kind) or validate_kind(declared_kind) input_values = args.input or [] new_paths = collect_input_paths(input_values, {".md"}) if input_values else [] @@ -895,37 +934,29 @@ def update_command(args: argparse.Namespace) -> None: reports = load_reports(sorted(all_paths.values(), key=lambda path: str(path))) foreign_reports = [report["report_id"] for report in reports if report["dna_id"] != dna_id] if foreign_reports: - raise SystemExit( - f"Reports belong to another dna-id: {', '.join(foreign_reports)}" - ) - statistics = build_statistics(reports) + raise SystemExit(f"Reports belong to another dna-id: {', '.join(foreign_reports)}") + kind = enforce_single_kind(reports, kind) + for report in reports: + validate_focus(report["focus"], kind) + validate_focus(args.focus, kind) + statistics = build_statistics(reports, kind) user_inputs = list(args.user_input or []) - write_text( - dna_path, - dna_document_markdown( - dna_id, - reports, - statistics, - user_inputs, - previous_dna, - ), - ) - write_text( - template_path, - template_markdown(dna_id, dna_path.name, previous_template), - ) - print(f"Updated DNA document and template: {dna_path}") + write_text(dna_path, dna_document_markdown(dna_id, kind, reports, statistics, user_inputs, previous_dna)) + write_text(template_path, template_markdown(dna_id, kind, dna_path.name, previous_template)) + print(f"Updated DNA document and template ({KIND_LABELS[kind]}): {dna_path}") def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Build qualitative Douyin short-video DNA assets") + parser = argparse.ArgumentParser(description=f"Build qualitative {PLATFORM_LABEL} DNA assets per work kind") subparsers = parser.add_subparsers(dest="command", required=True) - report = subparsers.add_parser("report", help="Create one single-video DNA report") + kind_help = f"作品类型:{' / '.join(KINDS)}(默认 {DEFAULT_KIND})" + report = subparsers.add_parser("report", help="Create one single-work DNA report") report.add_argument("--input", action="append", required=True) + report.add_argument("--kind", help=kind_help) report.add_argument("--cover-image", help="Local cover / first-frame image used by visual-model analysis") - report.add_argument("--source-url", help="Original video URL kept as report evidence") - report.add_argument("--duration", help="Video duration in seconds, used for speech-density statistics") + report.add_argument("--source-url", help="Original work URL kept as report evidence") + report.add_argument("--duration", help="Video duration in seconds (video kind only), used for speech-density statistics") report.add_argument("--dna-id", required=True) report.add_argument("--sample-id", required=True) report.add_argument("--weight", default="1") @@ -935,6 +966,7 @@ def build_parser() -> argparse.ArgumentParser: build = subparsers.add_parser("build", help="Aggregate DNA reports into DNA document and template") build.add_argument("--input", action="append") + build.add_argument("--kind", help=kind_help) build.add_argument("--dna-id", required=True) build.add_argument("--user-input", action="append", default=[]) build.add_argument("--output-dir") @@ -942,6 +974,7 @@ def build_parser() -> argparse.ArgumentParser: update = subparsers.add_parser("update", help="Merge reports and translate user input") update.add_argument("--input", action="append") + update.add_argument("--kind", help=kind_help) update.add_argument("--dna", required=True) update.add_argument("--template", required=True) update.add_argument("--focus", action="append", default=[]) diff --git a/crews/main/skills/expert-douyin/workflows/account-benchmark.md b/crews/main/skills/expert-douyin/workflows/account-benchmark.md index 310d5849..885e1bfb 100644 --- a/crews/main/skills/expert-douyin/workflows/account-benchmark.md +++ b/crews/main/skills/expert-douyin/workflows/account-benchmark.md @@ -33,15 +33,17 @@ | 视频链接(对标账号代表作) | self-spawn subagent 走 `viral-chaser`(转录 + 时长 + 标题/描述 + 互动线索 + 关键帧) | | 对标视频评论 | `douyin-comments fetch --url <视频链接>` 直接抓取(默认前 40 条热度评论;摘要落 `douyin/ref/{benchmark-dna-id}/comments/{sample-id}.comments.md`) | | 本地文字稿 / 脚本 | 整理为 `.md` 直接输入 profiler | -| 用户提供的截图 / 数据 | 作为互动信号线索保留,不编造 | +| 用户提供的截图 / 数据 / 账号信息 | 作为互动信号、账号简介、发布时间与内容形式线索保留,不编造 | 选择建议: -1. 优先选择点赞、评论、分享信号强的视频;播放量只作参考之一。优先低粉高播账号的作品(内容形式更可学习)。 +1. 优先选择点赞、评论、分享信号强的视频;播放量只作参考之一。优先选择粉丝量少但互动(赞、评)高的账号的作品(内容形式更可学习)。 2. 抖音没有账号作品列表抓取工具;请用户提供对标账号的代表性视频链接,或由 `smart-search` 辅助发现候选账号后请用户确认。 -3. 账号级对标至少收集 10 条代表性视频;账号可获取视频不足 10 条时,提供全部并说明数量限制。 -4. 单条视频可以形成单条观察,但不得当成账号级稳定 DNA;多个样本才分析覆盖率和共性。 -5. 互动数据线索只说明"用户怎么投票",不直接等于内容质量;归因前先按 `review.md` 的混杂因素清单排除账号成熟度、投流、选题热度等干扰。 +3. 对标账号至少批量收集 10 条代表性作品(从账号发布列表提取,视频与图文分开建 DNA);可获取作品不足 10 条时,提供全部并说明数量限制。 +4. 单篇作品可以形成单篇观察,但不得当成稳定结论;多个样本才分析覆盖率和共性。 +5. 批量账号样本必须填**账号运营子模块**:账号简介写法(`account-bio`)、内容形式比例与发布习惯(`content-mix-cadence`,含发布时间段与混合节奏,如三篇图文对一篇视频);这两项只写进 DNA 文档,不进 template。 +5. 每条样本尽量记录账号简介、发布时间、内容形式、素材来源与授权;缺失写「未观测」,不虚构。 +6. 互动数据线索只说明"用户怎么投票",不直接等于内容质量;归因前先按 `review.md` 的混杂因素清单排除账号成熟度、投流、选题热度等干扰。 ### Step 2 - 建立对标 DNA @@ -54,6 +56,7 @@ douyin-style-profiler report \ --sample-id {sample-id} \ --cover-image path/to/frame.jpg \ --source-url "https://www.douyin.com/video/..." \ + --output-dir douyin/dna/{benchmark-dna-id}/reports \ --duration 89 ``` @@ -70,13 +73,13 @@ douyin/dna/{benchmark-dna-id}/{benchmark-dna-id}.dna.md douyin/dna/{benchmark-dna-id}/{benchmark-dna-id}.template.md ``` -对标 DNA template 也必须固定为七个部分:选题、标题(含封面)、起、承、转、合、CTA。后五个部分是语义结构,不限制实际时长占比;每一部分都要能从对标 DNA 文档推导。 +对标 DNA template 用语义段与目标 DNA 一致:视频 = 选题、标题与封面、内容创意、业务植入与 CTA、视频形态与制作指向、制作规格、口播文案(可选);图文 = 选题、标题与封面、内容创意与结构、正文表达、图组、业务植入与 CTA。每一部分都要能从对标 DNA 文档推导;账号运营子模块不进 template。 ### Step 3 - 模式分析与差异化(agent 推理) 基于对标 DNA 与样本数据,回答三个问题(不下没有证据的结论): -1. **它为什么有效**:对标账号的高表现内容在钩子、结构、人设、互动设计上有什么共性?哪些信号在多条视频中稳定出现? +1. **它为什么有效**:对标账号的高表现内容在选题、标题包装、内容形式、业务植入与 CTA、发布节奏、高数据创意与互动设计上有什么共性?哪些信号在多条视频中稳定出现? 2. **相对表现**:同一账号内部,哪类内容明显高于其他条(用用户提供的互动数据判断;无数据时只做内容面分析,不编数据)。 3. **差异化切入点**:我们的账号比对标强在哪、弱在哪?有哪些内容空白或人群空白可以切入?每个切入点说明依据和建议的验证方式(一条视频验证一个变量)。 @@ -97,16 +100,16 @@ douyin/dna/{base-dna-id}/{base-dna-id}.template.md 比较必须覆盖两层: -1. **17 维 DNA 文档**:逐个维度比较规则、证据和适用条件;封面 / 首帧需比较视觉特征与 AIGC 复现要素。 -2. **七部分 template**:逐项比较选题、标题(含封面)、起、承、转、合、CTA 的执行方式。 +1. **DNA 文档**:逐个比较选题与观看理由、标题与封面、内容创意、业务植入套路、互动引导与 CTA 套路、视频形态与制作指向、制作规格、口播文案子模块,以及账号运营子模块(简介写法、内容形式比例、发布习惯)。 +2. **template 语义段**:按作品类型逐项比较(视频看 Brief 相关段,图文看写作段)。 -每个维度和模板部分都输出四类结论: +每个维度和模板语义段都输出四类结论: | 类别 | 判断标准 | | --- | --- | | 保持 | 基线已有优势,与目标观众和商业定位一致 | | 引入 | 对标更有效,且不冲突业务事实、合规边界和用户偏好 | -| 局部借鉴 | 只适合钩子、开头、结构、互动等局部场景 | +| 局部借鉴 | 只适合选题、标题与封面、内容创意、制作指向、发布习惯等局部场景 | | 不采纳 | 仅依赖孤例、冲突商业定位、风险高或难稳定执行 | 每项至少说明: @@ -120,26 +123,26 @@ douyin/dna/{base-dna-id}/{base-dna-id}.template.md 对比结果不自动更新基线 DNA。用户明确采纳后,选择一种方式: -1. **局部 DNA 融合**:用户决定采纳对标 DNA 的某个维度或模板部分时,直接读取对标 DNA 文档 / template 中对应内容,把它当作用户提供的一条融合要求,更新基线 DNA;不需要重新抓取参考视频,也不需要重新生成对标视频的基线 report。 +1. **局部 DNA 融合**:读取对标 DNA 文档 / template 中对应内容,把它当作用户提供的融合要求,更新基线 DNA。 2. **局部样本借鉴**:仅在用户明确希望引入原视频证据、权重或 focus 时,才将对标视频重新生成属于基线 DNA 的 report,并用 `--focus` 限定采纳维度。 -3. **偏好转译**:用户表达"钩子再冲突一点""开头节奏更快"等要求时,按 `style-dna.md` 的用户输入转译更新,并落到七部分 template 的具体执行字段。 +3. **偏好转译**:用户表达“选题更像对标”“实拍比例提高”等要求时,按 `style-dna.md` 的用户输入转译更新。 4. **明确不改 DNA**:仅作为本次选题或制作参考,不落盘到 DNA。 局部 DNA 融合流程: -1. 明确采纳范围:17 维中的维度、七部分中的模板部分,或两者组合。 +1. 明确采纳范围:维度、template 语义段,或两者组合。 2. 读取对标 DNA 文档 / template 的对应规则、适用条件和例外。 -3. 将其整理为一条可转译的输入,必须包含来源 `dna-id`、采纳范围和具体规则。 +3. 整理为一条可转译输入,包含来源 `dna-id`、采纳范围和具体规则。 4. 在基线 DNA 上执行无新增样本的 update: ```bash douyin-style-profiler update \ --dna douyin/dna/{base-dna-id}/{base-dna-id}.dna.md \ --template douyin/dna/{base-dna-id}/{base-dna-id}.template.md \ - --user-input "采纳 {benchmark-dna-id} 的钩子部分:xxx" + --user-input "采纳 {benchmark-dna-id} 的内容形式与发布节奏:xxx" ``` -5. Agent 把该输入视作用户提供的参考要求,转译为基线 DNA 的 affected dimensions、聚合结论、创作规则和七部分 template 执行字段。 -6. 融合时必须检查与基线证据、业务事实、合规边界和用户偏好的冲突;冲突保留说明,不静默覆盖。 +5. Agent 把该输入转译为基线 DNA 的 affected dimensions、聚合结论、创作规则和 template 执行字段。 +6. 融合时检查与基线证据、业务事实、合规边界和用户偏好的冲突;冲突保留说明,不静默覆盖。 -更新后必须同步修订基线 DNA 文档与 template,并保留来源说明。 +更新后同步修订基线 DNA 文档与 template,并保留来源说明。 diff --git a/crews/main/skills/expert-douyin/workflows/account-setup.md b/crews/main/skills/expert-douyin/workflows/account-setup.md index eca8e1b7..532fbbef 100644 --- a/crews/main/skills/expert-douyin/workflows/account-setup.md +++ b/crews/main/skills/expert-douyin/workflows/account-setup.md @@ -2,7 +2,7 @@ 新号起号、账号定位梳理、内容支柱搭建、默认 `dna-0` 初始化、老号接手与诊断走这个 Workflow。独立账号对标走 `account-benchmark.md`。 -起号拆成六件事:定位清楚、观看理由成立、标签稳定、内容有用、互动真实、复盘持续。方法库里的技巧和案例都是启发,不要把具体数字当成保证;平台功能入口、算法权重、处罚规则按待确认信息处理。产出要能直接执行:表格、清单、脚本简报、30 天节奏或复盘动作,少写空泛建议,多给"下一条视频该怎么做"。 +起号拆成六件事:定位清楚、观看理由成立、标签稳定、内容有用、互动真实、复盘持续;平台功能入口、算法权重、处罚规则按待确认信息处理。产出要能直接执行:表格、清单、脚本简报、选题池或复盘动作,少写空泛建议,多给"下一条视频该怎么做"。 ## 入口判断 @@ -59,7 +59,7 @@ 2. **观看理由**:陌生人为什么要看完这条而不是划走;看完能记住账号的哪个身份、冲突、热情或承诺。每条视频都要能回答这两个问题,回答不了的选题不做。 3. **内容支柱**:3-5 个,分别承接搜索、信任、故事、证明、转化或留存。 4. **关键词地图**(标签反推 + 搜索预埋): - - 选 5-10 个对标账号,优先低粉高播、近期更新、评论真实、内容形式可学习的账号;请用户提供账号名或视频链接,Agent 用 `viral-chaser` 逐个拆解,并用 `douyin-comments` 直接抓取对标视频评论(不依赖用户提供截图),记录主标签、细分人群、核心场景、常见痛点、标题高频词、评论高频问题。 + - 选 5-10 个对标账号,优先低粉高互动、近期更新、评论真实、内容形式可学习的账号;请用户提供账号名或视频链接,Agent 用 `viral-chaser` 逐个拆解,并用 `douyin-comments` 直接抓取对标视频评论(不依赖用户提供截图),记录主标签、细分人群、核心场景、常见痛点、标题高频词、评论高频问题。 - 汇总成 `1 个主标签 + 2-4 个场景词 + 3-5 个人群痛点词`,并按关键词层级展开:核心词(赛道本体)、长尾痛点词(用户真实搜索句)、场景词(时间/人群/预算/身份)、转化词(清单/步骤/避坑/对比/测评/教程)。 - 这些词写进昵称、简介、置顶视频、前 10 条标题、口播首句、合集名称和结尾关注理由。 5. **主页文案**:昵称、简介、置顶视频规划。 @@ -93,7 +93,7 @@ 2. 目标 DNA 固定为 `dna-0`。 3. 每条生成 report。 4. 运行 `douyin-style-profiler build --dna-id dna-0`,生成 DNA 文档与 template。 -5. 把选题、钩子、结构、口播、可复用规则和例外发送用户确认。 +5. 把定位、选题、标题包装、账号简介、内容形式与比例、发布习惯、高数据创意、口播 DNA 与制作管线发送用户确认。 6. 用户调整意见按 `style-dna.md` 的"用户输入转译"更新到 `dna-0`。 用户没有明确权重时,所有 report 默认权重为 1。 @@ -106,7 +106,7 @@ Agent 基于 `business_knowledge.md` 和关键词地图提炼搜索关键词, 1. 优先找观众相近、承诺清晰、近期仍活跃的账号;优先低粉高播账号(粉丝少但播放高,说明内容形式可学习)。 2. 优先选择点赞、评论、分享信号强的视频;播放量只作参考之一。 -3. 账号级初始参考建议收集 10 条视频;用户明确提供单条或少量样本时仍可建立 `dna-0`,但必须标注覆盖不足,不得称为账号级稳定 DNA。 +3. 对标账号初始参考建议批量收集 10 条作品(视频进 `dna-0`,图文进 `dna-0-note`);用户明确提供单篇或少量样本时仍可建立 DNA,但必须标注覆盖不足,不得称为稳定结论。批量账号样本才填账号运营子模块(简介写法、内容形式比例、发布习惯)。 4. 抖音没有账号作品列表抓取工具;用户找到账号或视频链接后,Agent 再用 `viral-chaser` 拆解和提炼。 用户补回账号名或视频链接后,回到路径 A。 @@ -114,7 +114,7 @@ Agent 基于 `business_knowledge.md` 和关键词地图提炼搜索关键词, #### 路径 C:用户拒绝外部参考或已有明确想法 1. 结合 `business_knowledge.md`、用户定位、目标观众、变现方式和用户思路,提炼 `dna-0`。 -2. Agent 直接按 17 维框架编写: +2. Agent 直接按维度框架编写(视频用 `video-dna-framework.md`,图文用 `note-dna-framework.md`): ```text douyin/dna/dna-0/dna-0.dna.md @@ -123,7 +123,7 @@ douyin/dna/dna-0/dna-0.template.md 3. DNA 文档必须标明来源是"业务信息 + 用户指定方向",没有视频样本统计。 4. 能确定的规则写为执行要求;未确定维度写"待校准",不得虚构单条证据。 -5. Template 仍必须给出可执行的七部分生产结构;不能只有抽象定位词。 +5. Template 仍必须给出可执行语义段:视频 = Brief 正文 + 口播文案模板;图文 = 图文写作模板。不能只有抽象定位词。 6. 将 DNA 摘要和待校准点发给用户确认,再按用户意见转译修订。 后续获得真实视频样本时,按 `style-dna.md` 生成 report 并更新 `dna-0`。 @@ -142,7 +142,7 @@ douyin/dna/dna-0/dna-0.template.md 检查要求: - DNA 文档说明样本来源、覆盖数量、权重、例外和待校准点。 -- Template 能由 DNA 文档推导,直接指导选题、标题、钩子、结构、口播、画面和互动。 +- Template 能由 DNA 文档推导,直接指导定位、选题、标题包装、内容形式、发布节奏、高数据创意、互动与制作交接。 - 用户输入已转译为维度级规则,不是原话堆积。 - 对标样本 report 不混入 `dna-0`;用户采纳后,只有转译后的规则通过局部 DNA 融合进入 `dna-0`。 @@ -171,7 +171,7 @@ douyin/dna/dna-0/dna-0.template.md ### Step 1 - 获取老号样本 1. 抖音没有账号作品列表抓取工具;请用户提供老号最近的代表性视频链接(原则上至少 10 条,账号作品不足 10 条时提供全部并说明)。 -2. 拿到链接后逐条 self-spawn subagent 走 `viral-chaser` 拆解,剔除重复、删除和无效样本;保留标题、描述、时长、互动线索和关键帧。 +2. 拿到链接后逐条 self-spawn subagent 走 `viral-chaser` 拆解,剔除重复、删除和无效样本;保留标题、描述、发布时间、账号简介、内容形式、时长、互动线索和关键帧。 3. 用户也可补充后台截图(播放、完播、粉丝画像),作为诊断线索,不编造。 ### Step 2A - 账号基本可用,用户让 Agent 接手 @@ -186,7 +186,7 @@ douyin/dna/dna-0/dna-0.template.md ### Step 2B - 用户认为账号不太可用,需要推倒重来 -1. 基于 Step 1 拿到的样本提炼出常用署名、核心卖点、选题方向、账号名称、核心 CTA 等基础信息,将这些信息与用户确认。 +1. 基于 Step 1 拿到的样本提炼定位、账号简介、选题组合、标题包装、内容形式、发布节奏、核心卖点与 CTA 等基础信息,将这些信息与用户确认。 2. 结合上述信息,走「从零开始打造新号」流程。 3. 如果 `business_knowledge.md` 不全,可以先从 Step 1 的样本中提取,不足的信息与用户讨论。 diff --git a/crews/main/skills/expert-douyin/workflows/content-production.md b/crews/main/skills/expert-douyin/workflows/content-production.md index 9866558e..fc084a80 100644 --- a/crews/main/skills/expert-douyin/workflows/content-production.md +++ b/crews/main/skills/expert-douyin/workflows/content-production.md @@ -2,7 +2,7 @@ 从选题到发布的完整内容生产。用户说"帮我做条抖音视频""发个抖音""这条照着做一条""这个选题我们也做一条"走这个。 -视频制作分工是硬边界(用户钦定):main agent 只做**基于已有素材的组装与轻剪辑**;出脚本、从零端到端制作一律委托 `content-producer`。本 workflow 的价值在于:用 DNA template 锁定内容与风格要求、编排输入分支与确认节点、衔接制作(自做或委托)、把关质量与发布记录。 +**视频作品分工硬边界**:main agent 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督并推动作为 subagent 的 `content-producer`、成片后的发布与运营;也直接做图文内容与**已有视频素材的简单加工**(`video-edit` / `talking-head-cut`)。视频全案的成片制作一律委托 `content-producer`。本 workflow 的价值在于:用 DNA 锁定选题、包装、内容创意、视频形态与制作规格,编排输入分支与确认节点,衔接制作并把关发布记录。 ## Step 0 - 入口判断 @@ -21,7 +21,7 @@ | --- | --- | --- | | 素材组装 / 轻剪辑 | 用户手里有可用素材 | main 直接做:`video-edit` / `talking-head-cut` / `ui-demo` | | 从零制作 | 没有素材,需要出脚本、拍摄/生成画面 | main 出制作简报,委托 `content-producer` | -| 脚本制作 | 用户已有脚本 | 脚本交 `content-producer` 制作 | +| 脚本制作 | 用户已有脚本 | 根据dna对脚本做必要修改,提交用户确认后,脚本交 `content-producer` 制作 | ### 3. 抖音链接的意图判断 @@ -54,7 +54,10 @@ douyin/dna/{dna-id}/{dna-id}.dna.md douyin/dna/{dna-id}/{dna-id}.template.md ``` -DNA template 是选题、标题、钩子、结构、口播、画面和互动路线的直接执行依据;DNA 文档用于理解稳定性、例外和适用条件。委托 `content-producer` 时,把 template 的执行要求写进制作简报,让制作方按 DNA 生产。 +DNA template 是 main agent 的生产输入模板: + +- **视频 DNA 的 template = Brief 正文模板 +(可选)口播文案模板**,覆盖选题与观看理由、标题与封面、内容创意、业务植入与 CTA、视频形态与制作指向、制作规格、口播文案(如果是口播类视频创作),它不规定创作细节:逐句台词、镜头表、转场或编码参数,这些归 Content Producer决定; +- **图文 DNA 的 template = 图文写作模板**。 ### 2. 读取业务知识 @@ -91,7 +94,7 @@ DNA template 是选题、标题、钩子、结构、口播、画面和互动路 > Agent 的一般内容判断 ``` -DNA 约束的是选题、钩子、结构、口播、画面和互动方式;不能覆盖用户指定素材、事实、合规边界和转化要求。 +DNA 约束的是选题与观看理由、标题与封面写法、内容创意原型、业务植入套路、互动引导与 CTA 套路、视频形态与制作指向、制作规格;口播文案子模块只在明确启用时约束口播;账号运营子模块(简介写法、内容形式比例、发布习惯)只用于起号、对标与发布节奏决策,不进 Brief。DNA 不能覆盖用户指定素材、事实、合规边界和转化要求。 ## Step 2 - 素材获取与整理 @@ -161,6 +164,7 @@ DNA 约束的是选题、钩子、结构、口播、画面和互动方式;不 - 标题硬限制:不超过 30 字(抖音创作者平台上限)。 - 简介提及产品或业务,但不放明显引流信息;禁止二维码、联系方式;可引导用户主动搜索或点头像看主页。 - 话题标签按 DNA template 的策略组织:主标签 + 场景词 + 痛点词,不堆砌。 +- 业务植入与 CTA 按 DNA 的 `biz-implant` / `interaction-cta` 落位:植入位置、载体与衔接句写清楚,一条只放一个主行动,不堆叠 CTA。 ## 【确认】标题与文案 @@ -172,42 +176,57 @@ DNA 约束的是选题、钩子、结构、口播、画面和互动方式;不 ### 路线 A:素材组装 / 轻剪辑(main 直接做) -按 DNA template 的起承转合 CTA 结构编排素材: +只处理已有素材的简单加工,不升格为全案制作: -1. 按 template 各部分的画面与口播要求,把素材整理成剪辑顺序清单(哪段素材对应哪个部分、配什么口播/字幕)。 +1. 按 DNA 的核心传达、内容形式与互动目标整理剪辑顺序清单;若创作口播类视频,由 main 先写口播稿。 2. 口播类素材去口气词、剪高光 -> `talking-head-cut`。 3. 抽段拼接、加旁白 / BGM、烧字幕、编号合成 -> `video-edit`。 4. 素材缺口经 AIGC 片段(`aigc-video-gen`)或免费素材库(`pexels-footage` / `pixabay-footage`)补充,补充素材在清单中标注来源。 5. 产品操作演示 -> `ui-demo` 录制。 +6. 若需求超出“简单加工”(需要重写叙事、全新分镜、全案生成),停止自做,改走 Brief 委托路线。 ### 路线 B / C:委托 content-producer 制作 -1. 产出**制作简报** `douyin/outputs//brief.md`,至少包含:选题与观看理由、标题与文案、DNA template 各部分执行要求(钩子类型与开场口播、结构与节奏、画面与镜头、声音、互动引导)、素材清单(如有)、时长带、验收标准(钩子兑现、结构完整、DNA 检查项)。 -2. 参考模式附上 `viral-chaser` 拆解报告路径,作为 brief 的一部分。 -3. spawn `content-producer` 作为 subagent(或提示用户直接委托 content-producer),由其出脚本并完成端到端制作;脚本讨论也直接找 content-producer,main 不代写完整脚本。 -4. 跟进制作进展,避免长时间卡住;成品视频回传 `douyin/outputs//`。 - -## Step 6 - 成片自检 - -成品必须过 `video-review` 闸门(ffprobe 校验 + 黑帧扫描 + 音频电平 + 时长/分辨率一致性)。 +1. 产出**制作简报** `douyin/outputs//brief.md`(Brief 是 main / CP 的唯一交接物): + +```markdown +# 抖音视频制作 Brief + +- 视频名 / slug: +- platform:douyin +- workflow:reversal-ad / narration-video / collage-broll / 未指定(未指定 = CP 按其通用制作流程做,Stage 1 自定档位) +- 选题与观看理由: +- 核心传达: +- 内容创意:创意原型 + 展开逻辑 + 记忆点(+ 反转设计,如为反转植入类) +- 业务植入与 CTA:植入位置与方式原型 + 内容与业务的衔接句要求 + CTA 主目标与句式(只写要求,不写 DNA 规则原文) +- 标题与简介:发布标题、简介文案、话题标签(main 定稿) +- 封面要求:封面主文案 + 视觉方向 +- 制作规格:横屏 / 竖屏、时长带、画面风格、配音音色与声音形态、BGM 与音效、字幕 +- 口播文案:`voiceover.md` 绝对路径(口播类必填)/ 真人口播录音绝对路径 / 不适用 +- 素材清单:逐条**绝对路径** + 来源 + 授权(无素材时写「无,由 CP 按 Brief 取材」) +- 交付物与验收:`video.mp4` + `cover.jpg` + `final-deliver.md`,回报三者绝对路径;验收标准 +- 闸门:GATE A / GATE B 批准人(用户或 main 代理批准 + 批准范围) +- 禁止事项:事实与承诺边界、合规红线、禁用方向 +``` -- verdict = pass -> 继续。 -- verdict = warn -> 向用户说明警告项,由用户决定是否修正。 -- verdict = fail -> 回到 Step 5 修正后重跑,不带病交付。 +Brief 硬性规则: -## Step 7 - 封面 +- **不写 DNA**:Brief 里不出现 dna-id、DNA 文档路径或 DNA 规则原文——CP 看不到 main 的 DNA,只按 Brief 制作。DNA 的结论由 main 消化后写成 Brief 的具体要求。 +- **不建工作区**:main 不替 CP 建目录、不指定项目路径;CP 在自己的 workspace 下自建工作区。双方 T3 权限可互访取文件。 +- **素材给绝对路径**:main 负责素材准备(用户素材预处理、`ui-demo` 录屏、从 `campaign_assets/` 挑选),把绝对路径写进 Brief。 +- **甲乙方关系**:需求方向、品牌事实、发布文案归 main;制作方案、分镜、渲染参数归 CP。 -1. 读取 DNA template 的封面 / 首帧风格和封面 AIGC 提示词要素。 -2. 结合最终标题、目标观众和本条核心收益,补齐本次封面的主体、场景、构图、色彩、文字视觉和负向约束。 -3. 优先从成片中选帧作为封面(与内容一致);需要更强视觉冲击时用 `siliconflow-img-gen` 生成。 -4. 用户已提供封面时直接使用;用户提供素材不足时作为生成参考。 -5. 封面图发用户确认,确认后保存为 `douyin/outputs//cover.jpg`。 +2. 口播类视频:按 DNA 的 `narration-script` 子模块写口播终稿 `douyin/outputs//voiceover.md`,Brief 里给绝对路径;真人口播时指导用户按口播稿录音,完成后向用户取得录音文件。 +3. 参考模式下,把选题与创意结论写进 Brief 的「内容创意」段即可;`viral-chaser` 拆解报告是 main 的采样材料,**不作为 Brief 附件交给 CP**。 +4. spawn `content-producer` 委托制作:只交 Brief + 素材绝对路径 + 口播文案 / 录音;不指定 CP 的工作区与制作方案。 +5. Brief 变更时更新版本并推送变更要点;已开工中间产物按新版取舍,弃用部分记入交付说明。 +6. CP 交付后,按其回报的绝对路径把成片与封面取回 `douyin/outputs//`(`video.mp4` / `cover.jpg`),并把交付说明要点记入作品目录。 ## 【确认】成片与封面 第三个必停节点。成片与封面确认后才可发布。用户有意见按意见修改,直至确认。 -## Step 8 - 发布 +## Step 6 - 发布 发布走 `douyin-publish`(工具说明见包内 `douyin-publish` 文档): @@ -226,7 +245,7 @@ douyin-publish run --video douyin/outputs//<成片文件> --title " - 同一时间只能有一个 `douyin-publish` 发布任务在跑(浏览器 session 竞态),多平台分发时抖音这条必须串行。 - 限频:单抖音号每 24h ≤ 5 条;触发风控立即降级,30 分钟内不重试。 -## Step 9 - 记录 +## Step 7 - 记录 发布成功(拿到视频链接)后入库: diff --git a/crews/main/skills/expert-douyin/workflows/editing.md b/crews/main/skills/expert-douyin/workflows/editing.md index 48a73852..312f1395 100644 --- a/crews/main/skills/expert-douyin/workflows/editing.md +++ b/crews/main/skills/expert-douyin/workflows/editing.md @@ -10,16 +10,16 @@ | 用户的说法 | 改的层级 | 怎么走 | |-----------|---------|--------| -| "改下标题 / 简介 / 话题" | 文案层 | 按 DNA template 标题与文案维度重写,直接给候选 | +| "改下标题 / 简介 / 话题" | 文案层 | 按 DNA `title-cover` 重写,直接给候选 | | "剪紧一点 / 去口气词 / 把高光剪出来" | 轻剪层 | `talking-head-cut`(去口气词、结巴、静音,按发言内容剪高光) | | "加 BGM / 加字幕 / 补片头片尾 / 插一段素材" | 加工层 | `video-edit`(audio-mix / subtitles / extract / assemble) | | "结构调一下 / 把第二段提前 / 换个讲法" | 结构层 | 先出调整方案(新的段落顺序与时点)-> 确认 -> `video-edit` 重剪 | -| "换个封面" | 封面层 | 成片抽帧或 `siliconflow-img-gen` 按 DNA template 封面规则重做 | +| "换个封面" | 封面层 | 成片抽帧或 `siliconflow-img-gen` 按 DNA `production-spec`(画面风格)重做 | | "换个风格 / 方向不对" | 方向层 | 回到 `content-production.md` 从选题重新走 | ## 文案层 -标题、简介、话题标签按目标 DNA template 的标题与文案维度重写: +标题、简介、话题标签按目标 DNA `title-cover` 重写: 1. 目标 DNA 不确定时先确认(用户指定或默认 `dna-0`)。 2. 标题 ≤ 30 字;简介与话题按 template 策略;禁止引流信息。 @@ -38,13 +38,13 @@ ## 结构层 先出结构调整方案(新的段落顺序、每段时点、删留内容),用户确认后再动剪。 -结构动了通常钩子和节奏也要跟着调——按目标 DNA template 的起、承、转、合重新核对各段任务,别只动骨架不换节奏。 +结构调整先核对 `content-idea`(内容创意)与 `video-form`(视频形态);若口播文案子模块已启用,再按 `narration-script` 核对口播结构。不要把 DNA 扩写成逐镜规则——镜头与剪辑细节归 Content Producer。 ## 换封面 1. 用户直接指定封面图 -> 直接使用。 2. 用户说"从片子里选一帧" -> 抽帧候选给用户挑。 -3. 用户说"重做一张" -> 按 DNA template 封面 / 首帧风格与 AIGC 提示词要素,用 `siliconflow-img-gen` 生成,用户确认后替换 `cover.jpg`。 +3. 用户说"重做一张" -> 按 DNA `production-spec`(画面风格)与本次标题/核心收益,用 `siliconflow-img-gen` 生成,用户确认后替换 `cover.jpg`。 ## 改完必做 @@ -54,7 +54,7 @@ ## 改后发布 -用户要求改完后发布 / 重新发布时,走 `content-production.md` 的发布与记录流程(Step 8-9): +用户要求改完后发布 / 重新发布时,走 `content-production.md` 的发布与记录流程(Step 6-7): 1. **发布**:`douyin-publish open-page` + 登录态判定后 `run`;标题与文案用改后版本。 2. **记录**:`published-track record` 重新入库为新记录(新链接、新发布日期),`dna-meta.json` 沿用该视频目录的 DNA 绑定。 diff --git a/crews/main/skills/expert-douyin/workflows/review.md b/crews/main/skills/expert-douyin/workflows/review.md index 1b3da437..a5043f29 100644 --- a/crews/main/skills/expert-douyin/workflows/review.md +++ b/crews/main/skills/expert-douyin/workflows/review.md @@ -34,17 +34,22 @@ - 用户级留存数据不可得。 - 用户可提供创作者中心后台截图(完播、粉丝画像、流量来源),作为更高置信度的证据;没有就用库内指标。 -### 互动漏斗 → template 七部分 → 17 维映射 - -| 漏斗卡点 | 先怀疑的 template 部分 | 可回溯的 DNA 维度 | -|---------|----------------------|------------------| -| 播放低(推荐/点击瓶颈) | 选题、标题(含封面) | topic-angle、title-style、cover-frame | -| 点击后快速划走(完播估算低) | 起(钩子)、承 | hook、video-structure、narrative-rhythm、speech-rhythm | -| 点赞低 | 承、合(价值感与情绪落点) | professionalism、conflict-tension、tone | -| 评论低 | CTA、互动设计 | interaction-design、conflict-tension | -| 分享低 | 选题、合(社交货币) | topic-angle、conflict-tension | -| 收藏低 | 承(实用价值密度) | professionalism、video-structure | -| 关注转化低 | 合、CTA | signature、series-design、tone | +### 互动漏斗 → template 语义段 → DNA 维度映射(视频作品) + +| 漏斗卡点 | 先怀疑的 template 语义段 | 可回溯的 DNA 维度 | +|---------|--------------------------|------------------| +| 播放低(推荐/点击瓶颈) | 选题、标题与封面 | topic-angle、title-cover | +| 点击后快速划走(完播估算低) | 内容创意、制作规格、口播文案 | content-idea、production-spec、narration-script | +| 点赞低 | 内容创意、选题 | content-idea、topic-angle | +| 评论低 | 内容创意(讨论点)、口播文案 | content-idea、narration-script | +| 分享低 | 选题、内容创意 | topic-angle、content-idea | +| 收藏低 | 内容创意、制作规格 | content-idea、production-spec | +| 关注转化低 | 账号运营子模块、业务植入与 CTA | account-bio、content-mix-cadence、interaction-cta | +| 组件点击 / 私信 / 成交转化低 | 业务植入与 CTA | biz-implant、interaction-cta | +| 广告感重(负面评论、掉粉、植入点完播跳水) | 业务植入与 CTA、选题 | biz-implant、interaction-cta、topic-angle | +| 画面/时长/音色被吐槽 | 制作规格、视频形态与制作指向 | production-spec、video-form | + +图文作品(`dna-*-note`)改按图文 template 语义段归因:选题、标题与封面、内容创意与结构、正文表达、图组、业务植入与 CTA,对应维度 `topic-angle`、`title-cover`、`content-idea`、`body-voice`、`imageset-visual`、`biz-implant`、`interaction-cta`。 ### 平台混杂因素(归因前必排) @@ -84,7 +89,7 @@ content-calibrator eval --platform douyin --dna-id # 指定 DNA 1. 判定只看比值与走向,绝对值只作上下文。 2. 逐条排除平台混杂因素,输出替代假设检验结果。 -3. 回读 `douyin/dna//.dna.md` / `.template.md` 与待评估作品原文(`source_folder` 下的转录 / 简报),把趋势变化落到 template 七部分与 17 维。 +3. 回读 `douyin/dna//.dna.md` / `.template.md` 与待评估作品原文(`source_folder` 下的转录 / Brief / 口播终稿),把趋势变化落到 template 语义段与 DNA 维度。 ### Step 3 - 报告与标记 diff --git a/crews/main/skills/expert-douyin/workflows/style-dna.md b/crews/main/skills/expert-douyin/workflows/style-dna.md index b7a37b14..99dbf19a 100644 --- a/crews/main/skills/expert-douyin/workflows/style-dna.md +++ b/crews/main/skills/expert-douyin/workflows/style-dna.md @@ -1,43 +1,37 @@ -# 抖音内容 DNA 创建与更新 Workflow +# 抖音 DNA 创建与更新 Workflow -本 Workflow 只负责内容 DNA 三层产物的创建与更新:判断样本来源、选择目标 DNA、获取视频样本材料(`viral-chaser` 拆解)、调用 `douyin-style-profiler` 生成或更新 report / DNA 文档 / template。17 维提取与聚合方法以 `douyin-style-profiler` 为准(维度框架见其 `references/style-17d-framework.md`,初始版本已确认)。 +本 Workflow 负责 DNA report、DNA 文档与 DNA template 的创建与更新。维度框架以 `douyin-style-profiler` 的 `references/video-dna-framework.md`(视频)与 `references/note-dna-framework.md`(图文)为准(DNA v2)。 -边界说明: +## 边界 -- 账号初始化、定位梳理与默认 `dna-0` 初始化走 `account-setup.md`(该 workflow 会调用本 workflow 的样本获取与更新机制)。 -- 账号对标分析走 `account-benchmark.md`;本 workflow 只承担对标 DNA 的三层产物生成与更新,以及采纳后并入基线 DNA 的更新。 -- DNA 如何被用于内容生产(含仿照创作、素材组装、委托制作)由 `content-production.md` 规定,改片与文案调整由 `editing.md` 规定;本 workflow 不描述生产过程。 - -DNA 描述选题、钩子、口播表达、视觉制作、结构节奏与互动标记等内容规则;不描述发布操作与数据复盘。 +- DNA 是从一批作品样本中提取、聚合出的内容生产规则集,样本可以来自多个账号,也可以来自用户指定的一个账号的发布列表批量提取。 +- 账号初始化与默认 `dna-0` 建立走 `account-setup.md`;对标样本先走 `account-benchmark.md`。 +- DNA 如何用于内容生产走 `content-production.md`;改片走 `editing.md`;数据复盘走 `review.md`。 +- DNA 指导 main agent 出图文内容、视频 Brief 与(口播类的)口播文案;全片制作委托 `content-producer`。 ## 入口判断 走本 Workflow: -- "建 DNA / 更新 DNA / 提炼这条视频的风格" -- "把这条视频落到某个 DNA 上" -- "提取下这个账号的内容 DNA" +- “建 DNA / 更新 DNA / 提炼这个账号的风格” +- “把这条视频落到某个 DNA 上” +- “用户提供单条或多条视频,帮我提炼可复用模式” 不走本 Workflow: -- "帮我做条视频 / 照着这条仿一条 / 这个选题我们也做一条"(使用 DNA 生产)-> 走 `content-production.md` -- "改改这条的文案 / 重新剪一下" -> 走 `editing.md` -- "看数据 / 复盘 / 评估这个 DNA" -> 走 `review.md` -- "起号 / 梳理账号定位" -> 先走 `account-setup.md` -- "分析对标账号 / 对比风格差异" -> 走 `account-benchmark.md` +- 做内容 / 仿内容 → `content-production.md` +- 改文案 / 重剪 → `editing.md` +- 看数据 / 评估 DNA → `review.md` +- 起号 / 定位 → `account-setup.md` +- 对标比较 → `account-benchmark.md` ## 目标 DNA 选择 -1. 用户明确指定 `dna-id` 时,使用该 DNA。 -2. 用户明确说"落到默认 DNA"或未指定目标时,使用 `dna-0`。 -3. 用户意图是账号对标、模式对比时,走 `account-benchmark.md`,对标样本必须进入独立 `dna-id`,不得直接写入 `dna-0`。 -4. 找不到目标 DNA 文档时,先走 `account-setup.md` 建立 `dna-0`,或按用户明确指定的新 `dna-id` 初始化。 - -默认规则: - -- 除 `account-benchmark.md` 分流的对标样本外,未特别分流的新参考视频累积到 `dna-0`。 -- `dna-0` 不代表某个参考账号,而是当前工作区的默认内容生产规则集。 -- 用户可以随时把样本、偏好或局部借鉴明确落到任意已有 DNA。 +1. 用户指定 `dna-id` 时使用该 DNA。 +2. 未指定或说“默认 DNA”时使用 `dna-0`。 +3. **作品类型分流**:一个 `dna-id` 只承载一种作品类型。抖音默认 `dna-0` 是视频(`--kind video`),图文样本另建 dna-id(如 `dna-0-note`);混型 `build` 会直接报错。 +4. 对标样本必须进入独立 DNA,不直接写入 `dna-0`;采纳后再通过局部融合更新。 +5. 目标 DNA 不存在时,先走 `account-setup.md` 或按用户明确指定的新 `dna-id` 初始化。 ## 存储结构 @@ -51,148 +45,127 @@ douyin/dna/{dna-id}/ {dna-id}.template.md ``` -- 原始转录文本可以临时保存在 `douyin/ref/{dna-id}/transcripts/`,也可以来自用户提供的路径。 -- 生成后的 DNA report 必须进入目标 DNA 的 `reports/` 目录。 -- `sample-id` 必须可读且稳定;覆盖同名 report 前,先向用户说明该文件会被重算。 +原始转录可临时放在 `douyin/ref/{dna-id}/transcripts/`。生成后的 report 必须进入目标 DNA 的 `reports/` 目录;覆盖同名 report 前先向用户说明。 ## 样本获取 -抖音样本的观测物是视频:口播转录、画面、封面、时长与互动线索。获取方式: +**先判作品类型**(视频 / 图文):它决定用哪套维度框架、`--kind` 取值与目标 dna-id;同一个 DNA 不混型。 | 来源 | 处理 | | --- | --- | -| 抖音视频链接(`v.douyin.com` / `www.douyin.com/video/...`) | self-spawn subagent 走 `viral-chaser` 拆解,产出转录全文、分句时间戳、时长、标题/描述/作者、互动计数与关键帧 | -| 用户提供的文字稿 / 脚本 / 口述要点 | Agent 整理为 `.md` / `.txt` 后直接作为 profiler 输入 | -| 本地视频文件 | 没有转录时请用户提供文字稿,或确认是否先委托转写;不得凭空编造转录 | -| 用户直接的想法 / 偏好 | 不生成 report,按"用户偏好"转译进入目标 DNA | +| 抖音视频链接 | self-spawn subagent 走 `viral-chaser`,取得转录、时长、标题/描述、互动线索与关键帧 | +| 用户提供的文字稿 / 脚本 | 整理为 `.md` / `.txt`,保留用户提供的数据与账号线索 | +| 本地视频文件 | 需用户提供文字稿或确认转写;不得凭空编造转录 | +| 用户想法 / 偏好 | 不生成 report,按用户输入转译进入 DNA | -处理样本时: +每条样本尽量收集: -1. `viral-chaser` 产物转成样本材料:转录全文(含标题、描述、作者、时长、互动数据线索)整理为一个转录 `.md`,标题写在一级标题;关键帧中的封面 / 首帧图(或封面下载图)作为 `--cover-image`。 -2. 剔除重复、已删除、正文缺失或纯广告样本。 -3. 保留来源 URL、作者、发布时间和获取时间作为报告线索。 -4. Profiler 本身不限制样本量;一个样本生成单条 report,多个样本聚合统计。账号级初始化、老号诊断和对标比较可在 `account-setup.md` / `account-benchmark.md` 设置最低样本要求。 -5. 互动数据(播放/点赞/评论/分享/收藏)只来自 `viral-chaser` 返回或用户提供的数据线索,不得编造;选样本时可参考互动信号,但数据好坏不直接等于风格好坏。 -6. 封面 / 首帧缺失时保留缺失状态,不得用正文帧或想象补齐视觉证据。 +- 样本类型:账号作品 / 用户提供单条 +- 账号名、简介、主页承诺 +- 发布时间、内容形式、素材来源与授权 +- 播放、点赞、评论、分享、收藏等数据线索 +- 横竖屏、时长、口播/实拍/AIGC 形态 -## 建立或重建 DNA +缺失字段写「未观测」。数据只作证据,不自动判断风格好坏。 -适用场景:目标 DNA 下还没有 report,或用户要求基于当前输入整体重建。 +## 建立或重建 DNA ### Step 1 - 准备样本 -把可用视频整理为转录 `.md`(含封面 / 首帧图路径与时长),并确定目标 `dna-id`。未指定时使用 `dna-0`。 - -### Step 2 - 生成单条 report +1. 判定作品类型(视频 / 图文),据此选框架与目标 `dna-id`。 +2. 把视频整理为转录 `.md`,首个一级标题写标题/描述。 +3. 确定目标 `dna-id`、`sample-id`、样本权重和 focus。 +4. 整理账号观测信息,供 Agent 补进 report。 -每条视频执行一次: +### Step 2 - 生成单篇 report ```bash +# 视频样本(不传 --kind,默认 video) douyin-style-profiler report \ --input path/to/transcript.md \ --dna-id {dna-id} \ --sample-id {sample-id} \ --cover-image path/to/cover.jpg \ --source-url "https://www.douyin.com/video/..." \ - --duration 89 -``` + --duration 89 \ + --output-dir douyin/dna/{dna-id}/reports -可选参数: +# 图文样本 +douyin-style-profiler report \ + --input path/to/note.md \ + --kind note \ + --dna-id {dna-id}-note \ + --sample-id {sample-id} \ + --cover-image path/to/cover.jpg \ + --output-dir douyin/dna/{dna-id}-note/reports +``` -- `--weight N`:用户明确说某条参考价值非常高时使用。 -- `--focus DIMENSION`:用户明确说只借鉴钩子、开头、结构等局部时使用,可重复传入。 +生成 scaffold 后必须: -Agent 生成 scaffold 后必须回读转录原文(必要时回看关键帧),补齐 17 维单条结论、原文证据和可复用信号;钩子维度必须逐字摘录前 3 秒口播 / 首帧字幕;封面 / 首帧维度必须由视觉模型读取本地图片,输出可复现的 AIGC 提示词要素。 +1. 补齐「样本观测」:作品类型、样本来源、账号与简介、发布时间、数据线索、素材来源与授权(缺失写「未观测」)。 +2. 回读原文,补齐各维度的单篇结论、原文证据与可复用信号。 +3. 视频样本必须给出**视频内容形态**与**制作指向**(Content Producer `expert-video` 的某个 workflow,或 main 的 `video-edit` / `talking-head-cut` / `ui-demo`),只写真实存在的资源名。 +4. 视觉维度必须有封面 / 首帧 / 关键帧图片证据;口播文案子模块样本不足时写「未启用」。 +5. 账号运营子模块(简介写法、内容形式比例、发布习惯)只在样本来自对标账号批量提取时填写,单篇样本写「未观测」。 ### Step 3 - 聚合 DNA ```bash -douyin-style-profiler build --dna-id {dna-id} +douyin-style-profiler build --dna-id {dna-id} # 视频 DNA(默认 kind=video) +douyin-style-profiler build --dna-id {dna-id}-note --kind note # 图文 DNA ``` -Agent 聚合时必须读取全部 report,结合权重、focus、高频共性、高权重偏好、孤例和例外,修订: - -```text -douyin/dna/{dna-id}/{dna-id}.dna.md -douyin/dna/{dna-id}/{dna-id}.template.md -``` +Agent 必须读取全部 report,按权重/focus 聚合: -Template 聚合要求: - -1. 固定输出七个部分:选题、标题(含封面)、起、承、转、合、CTA。 -2. 选题和标题保持 profiler 规定的形态(选题角度推荐、受众关联角度;标题类型、参考标题、话题标签策略、封面 / 首帧风格与 AIGC 提示词要素)。 -3. 起、承、转、合、CTA 是固定语义部分(对应黄金开场、主体推进、高潮转折、收尾、互动引导),不是固定时长占比;每个部分可对应视频中的一个或多个段落。 -4. 每个部分必须写入 DNA 文档中对应维度的执行要求,包括本段任务、执行方式、必须做和避免项。 -5. 所有模板规则必须能从 DNA 文档推导;Agent 不得为了填满七部分而编造样本没有的规则。 - -多个样本时,定性结论必须说明覆盖多少条;单条特征只能写成单条观察,不得伪装成稳定共性。 +- 高频共性、高权重偏好、局部借鉴、孤例、例外分开写。 +- 标注样本覆盖度;单篇或少量样本不得称为稳定结论。 +- 视频形态必须聚合成明确的**制作指向**(Content Producer `expert-video` 的某个 workflow,或 main 的素材加工技能),供 Brief 的 `workflow` 字段直接引用。 +- 高数据内容要回读创意、形态与包装,不能只归因。 +- 为每个维度写聚合结论、报告依据和可执行规则。 +- 确保 DNA 文档能推导 template;但账号运营子模块的结论只留在 DNA 文档。 ## 更新已有 DNA -适用场景:目标 DNA 已存在,新增样本、偏好、局部借鉴或表现反馈。 - ### 新增样本 -1. 先为新视频生成属于目标 DNA 的 report。 -2. 再执行: +生成新 report 后运行: ```bash douyin-style-profiler update \ - --input douyin/dna/{dna-id}/reports/{sample-id}.report.md \ + --input douyin/dna/{dna-id}/reports/{new-sample}.report.md \ --dna douyin/dna/{dna-id}/{dna-id}.dna.md \ --template douyin/dna/{dna-id}/{dna-id}.template.md ``` -3. Agent 根据新的加权统计和 17 维证据,同步修订 DNA 文档与 template。 +再由 Agent 重新审视聚合结论并同步修订 template。 ### 用户偏好 -用户意见不是直接入库的规则。Agent 必须先理解其指向,再转译到具体维度和执行要求,例如"开头冲突再前置一点"应落到 3秒钩子、视频结构模式、口播节奏等维度。 - -来自另一个 DNA 文档或 template 的局部结论,也按用户提供的参考要求处理:必须记录来源 `dna-id`、采纳范围、具体规则和冲突说明,再转译到当前 DNA 的对应维度与七部分 template。它不要求重新抓取或重新提取原视频。 - -可传入: - -```bash ---user-input "开头冲突再前置一点" -``` - -转译结果写入 DNA 文档的"用户输入转译区",并以可执行规则进入 template 的对应部分。原话不能成为 template 里的抽象口号。 +用户偏好不直接入库。通过 `--user-input` 传入后,Agent 映射到具体维度,并把原话转译为具体维度的创作规则与 Brief 规则。 ### 局部借鉴 -用户只希望借鉴某条视频的钩子、开头或结构时: - -1. 为该视频生成目标 DNA 的 report。 -2. 用 `--focus` 限定参与影响的维度。 -3. 更新 DNA 文档与 template,并在报告中保留来源和 focus 说明。 +对标 DNA 的局部规则必须先说明来源、适用条件和影响维度,再融合进目标 DNA;不得整包照搬。 ### 表现反馈 -来源:`content-calibrator` 的 DNA 表现评估报告(`douyin/dna/{dna-id}/evals/*.eval.md`)。评估回答"这个 DNA 好不好、哪些部分好/不好",本 workflow 负责把**用户确认采纳**的评估结论转译进 DNA。 - -1. 前提:评估报告已存在,且用户逐条确认了要采纳的建议(未确认的建议不动 DNA)。 -2. 每条采纳建议按参考输入处理:转译到具体维度和执行要求(如"播放稳定但评论持续走低,结尾提问太泛"→ 互动设计维度 + CTA 部分表达方式),经 `douyin-style-profiler update --user-input` 传入。 -3. 转译结果写入 DNA 文档的「表现反馈区」(与「用户输入转译区」并列),每条记录:来源 eval 文件、affected dimensions、DNA 文档修改、template 修改。 -4. 同步修订 template 对应部分;表现反馈只改规则表达,不引入样本未覆盖的新风格。 -5. 趋势类证据(比值走向)只支持方向性调整(加强/弱化既有规则),不支持凭空新增维度规则——新增规则仍需样本或用户输入支撑。 +复盘产生的建议先列证据与影响维度,经用户确认后写入 DNA;不得把一次数据波动直接升格为 DNA 规则。 ## DNA 使用接口 -本 Workflow 不描述如何用 DNA 生产:生产 workflow 自行读取 `douyin/dna/{dna-id}/{dna-id}.dna.md` 与 `{dna-id}.template.md`,按 template 七部分执行,见 `content-production.md` / `editing.md`。 - -反馈回流判定:来自生产、改片或复盘的成品修改意见,先判断是否可复用偏好——只有可复用偏好才经本 workflow「更新已有 DNA」进入 DNA;单次修改留在稿件审阅记录,不动 DNA。参考视频的风格吸收("把这条的风格融入到我们的 DNA")属于本 workflow 的新增样本 / 局部借鉴场景,不是生产流程的一部分。发布数据驱动的风格优化走「表现反馈」:`content-calibrator` 评估产出建议 → 用户逐条确认 → 本 workflow 转译进 DNA;评估本身不改 DNA。 +- **图文内容**:读取图文 DNA 文档与 template,main agent 直接生产。 +- **视频全案**:读取视频 DNA 文档与 template,main agent 产出 **Brief**(+ 口播类的口播文案)。Brief 写明选题与观看理由、标题与简介、内容创意、`workflow`(视频形态的制作指向)、制作规格(横竖屏 / 时长带 / 画面风格 / 配音音色)、素材清单与授权(绝对路径)、验收标准、闸门批准人。 +- **Brief 不含 DNA 信息**:Content Producer 看不到 main 的 DNA,只按 Brief 制作;也不要把 DNA 文档路径写进 Brief。 +- **口播类视频**:口播文案子模块启用时,口播终稿由 main agent 写好并随 Brief 交付;真人口播时由 main agent 指导用户录音并向用户取得录音文件。CP 不重写策略文案。 +- **工作区**:main 不替 CP 建工作区,也不指定项目目录;CP 自建工作区,双方 T3 权限可互访取文件。 ## 对标接口 -对标账号或一组对标视频不得默认落入现有 DNA;分析流程、逐项对比与采纳判断以 `account-benchmark.md` 为准。本 Workflow 只承担两件事: - -1. 对标 DNA 的三层产物生成与更新:使用独立 `dna-id`(如 `dna-benchmark-{slug}`,同组后续更新复用),样本获取、report、build/update 同本 workflow 的创建/更新流程。 -2. 用户采纳后,按「更新已有 DNA」执行并入基线 DNA 的更新:局部 DNA 融合、局部样本借鉴或偏好转译;默认优先局部 DNA 融合,不要求重新提取原视频。 +对标样本进入独立 `dna-id`(同样按作品类型分流);比较时输出选题、标题与封面、内容创意、业务植入与 CTA、视频形态与制作指向、制作规格的差异,以及(账号批量样本才有的)账号运营子模块差异:简介写法、内容形式比例、发布习惯。用户明确采纳后才通过局部融合写进目标 DNA。 ## 编排原则 -- Workflow 负责选择路径和衔接工具,不复制 profiler 的 17 维定义。 -- Agent 判断必须回读转录原文和 report,不能只依赖统计表。 -- 样本统计描述内容模式,不替代事实核查、合规审核和商业判断。 -- 用户确认只用于初始化方向或采纳建议,不是登记资产的前置门槛。 +- 一个生产任务只使用一个 DNA,且作品类型与任务一致;需要融合时先更新 DNA。 +- 样本、用户输入、数据反馈必须可追溯。 +- 账号运营子模块(简介写法、内容形式比例、发布习惯)只在对标账号批量样本下填写,且只进 DNA 文档不进 template;覆盖度不足就写未观测。 +- Template 只写 main agent 可执行的规则:视频类 = Brief 正文模板 + 口播文案模板;图文类 = 图文写作模板。都不写成片制作细节。 diff --git a/crews/main/skills/expert-ir/SKILL.md b/crews/main/skills/expert-ir/SKILL.md index 21001304..fb970b7e 100644 --- a/crews/main/skills/expert-ir/SKILL.md +++ b/crews/main/skills/expert-ir/SKILL.md @@ -1,6 +1,6 @@ --- name: expert-ir -description: 投资人关系(IR)专家。承接投资人发掘、融资沟通流水线(状态机跟进)、项目申报(认定/补贴/大赛/资质)的完整工作。零散的投资人记录、进展查询等操作也可以直接做。不涉及商务获客(找客户/“截流”/商业情报走 expert-bd)。 +description: 投资人关系(IR)专家。承接投资人发掘、融资沟通流水线(状态机跟进)、项目申报(认定/补贴/大赛)的完整工作。零散的投资人记录、进展查询等操作也可以直接做。不涉及商务获客(找客户/“截流”/商业情报走 expert-bd)。 metadata: openclaw: emoji: 📈 @@ -18,7 +18,7 @@ metadata: | 融资材料 | Investor Materials | Pitch Deck / One-Pager / 投资人备忘录 / 财务模型 / 加速器申请材料 | | 投资人触达 | Investor Outreach | 冷邮件、暖介绍请求、跟进邮件、投资人更新等沟通文案 | | 融资流水线 | Investor Pipeline | 完整的融资沟通编排:发掘 → 材料 → 触达 → 跟进 → 状态机推进 | -| 项目申报 | Project Application | 高企认定 / 加速器申请 / 政府补贴 / 软著商标专利配套 / 行业奖项:材料准备 + 时间线 + 状态跟踪 | +| 项目申报 | Project Application | 高企认定 / 加速器申请 / 政府补贴 / 行业奖项:材料准备 + 时间线 + 状态跟踪 | ## 执行方式与定时任务 @@ -41,7 +41,7 @@ metadata: |------|------|------| | `ir-record` | 投资人档案 / 接触历史 / 项目申报数据库(状态机数据层) | `ir-record` | -跨领域通用技能:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`email-ops`(邮件发送)、`market-research`(基金/竞品尽调)、`pitch-deck`(HTML 路演材料)、`council`(商业模式多视角复盘)、`swcr-register`(软著材料生成)。 +跨领域通用技能:`smart-search`(构造搜索 URL)、`browser-guide`(浏览器操作)、`email-ops`(邮件发送)、`market-research`(基金/竞品尽调)、`pitch-deck`(HTML 路演材料)、`council`(商业模式多视角复盘)。 ## 数据与记录 @@ -54,7 +54,8 @@ metadata: ## 边界 - 商业模式打磨(融资前的电梯版梳理 / 5 问结构化):由 agent 结合 `business_knowledge.md` 直接与用户完成,多路径权衡用 `council`;打磨结论落 `MEMORY.md` 后才进入投资人接触。 -- 项目申报 / 补贴 / 创业大赛 → 包内 Project Application Workflow(数据落 `ir-record` 的 applications 表);软著材料生成走顶层技能 `swcr-register`。 +- 项目申报 / 补贴 / 创业大赛 → 包内 Project Application Workflow(数据落 `ir-record` 的 applications 表)。 +- 软著 / 商标 / 专利等知识产权申报:不在职责范围,不承接。 - 商务获客(找客户 / 评论区 / 情报)→ `expert-bd`。 ## 红线 diff --git a/crews/main/skills/expert-ir/workflows/project-application.md b/crews/main/skills/expert-ir/workflows/project-application.md index 844b5dec..60548ed7 100644 --- a/crews/main/skills/expert-ir/workflows/project-application.md +++ b/crews/main/skills/expert-ir/workflows/project-application.md @@ -1,8 +1,10 @@ # Project Application(项目申报) -帮用户准备、跟踪各类外部申报项目:高新技术企业认定、加速器申请、政府补贴、资质认证(软著 / 商标 / 专利配套)、行业奖项。涵盖材料生成 + 时间线管理 + 状态跟踪。 +帮用户准备、跟踪各类外部申报项目:高新技术企业认定、加速器申请、政府补贴、行业奖项。涵盖材料生成 + 时间线管理 + 状态跟踪。 -**依赖**:`swcr-register`(软著材料)、`market-research`(行业数据 / 竞品分析)、Investor Materials Workflow(BP / One-Pager)、`ir-record`(applications 表状态跟踪)。 +软著 / 商标 / 专利等知识产权申报不在本 workflow 范围,不承接。 + +**依赖**:`market-research`(行业数据 / 竞品分析)、Investor Materials Workflow(BP / One-Pager)、`ir-record`(applications 表状态跟踪)。 ## 适用场景 @@ -11,18 +13,15 @@ - "我想申请高新技术企业认定 / 专精特新 / 科技型中小企业" - "我看到 X 加速器在招创业团队,能帮我准备申请吗" - "政府有 Y 补贴项目,截止日期 Z,能帮我看下材料吗" -- "我想申请软著 / 商标 / 专利" - "我要申报 X 行业奖项" ## 常见申报类型 | 类型 | 典型材料 | 材料协作 | |------|---------|---------| -| 高新技术企业认定 | 知识产权 + 研发费用 + 人员名单 + 财务审计 | `swcr-register` + `market-research` | +| 高新技术企业认定 | 知识产权 + 研发费用 + 人员名单 + 财务审计 | `market-research` | | 加速器申请 | BP + One-Pager + 团队介绍 + 牵引数据 | Investor Materials Workflow(包内) | | 政府补贴 | 申报书 + 财务报表 + 项目实施方案 | `market-research`(行业数据)| -| 软著登记 | 源程序文档 + 操作手册 | `swcr-register` | -| 商标 / 专利 | 技术交底书 + 权利要求书 | 直接走,不委派 | | 行业奖项 | 案例描述 + 客户证言 + 量化数据 | `market-research`(行业 baseline)| --- @@ -44,7 +43,6 @@ | 材料 | 委派给 | |------|--------| -| 软著材料(源程序 + 操作手册)| `swcr-register` | | 行业市场数据 / 竞品分析 | `market-research` | | BP / One-Pager | Investor Materials Workflow(包内) | @@ -83,7 +81,6 @@ ir-record update-application --id --status ## 与其他环节的关系 -- **`swcr-register`**(顶层技能):软著专用(频繁需要的子材料,合规性边界) - **`market-research`**(顶层技能):行业数据(多个申报类型需要) - **Investor Materials Workflow**(包内):加速器申请等需要 BP / One-Pager - **商业模式打磨**(IR 模式 1):申报前先打磨商业模式(很多申报材料要先有清晰的商业故事) @@ -111,6 +108,5 @@ ir-record update-application --id --status ## Notes -- 软著 / 商标 / 专利的"材料生成"严格走 `swcr-register` skill(合规性边界) - 财务审计报告、税务证明等"硬材料"由用户/会计师提供,AI 不替生成 - 申报通过率不承诺,AI 只保证材料齐整 / 表达清晰 / 时间线追踪 diff --git a/crews/main/skills/expert-twitter/tools/twitter-post/SKILL.md b/crews/main/skills/expert-twitter/tools/twitter-post/SKILL.md index a383852f..1732756b 100644 --- a/crews/main/skills/expert-twitter/tools/twitter-post/SKILL.md +++ b/crews/main/skills/expert-twitter/tools/twitter-post/SKILL.md @@ -51,7 +51,35 @@ camoufox-cli --session twitter --persistent --headed --json open "https://x.com/ ## 通用约束 - 文件上传用 forked camoufox-cli 的 `upload` 命令(`camoufox-cli --session --persistent --json upload `,底层 Playwright `setInputFiles`,无需 DataTransfer hack) -- 正文输入使用 `type` + `slowly: true`,不要用 `fill()` +- 正文输入:**CJK(中文/日文/韩文)内容禁用 `type` 命令**——camoufox-cli `type` 逐字符按键流与 X 编辑器(Draft.js)异步处理存在竞态,中文实测丢字+乱序(2026-09-13 事故,首字被挪到结尾、中段整段消失,含分段 type+停顿仍错乱)。CJK 正文一律走下方「CJK 正文输入与校验闸门」的 eval + `document.execCommand("insertText")` 整段插入;纯 ASCII 短文本仍可用 `type`。**不要用 `fill()`** + +### CJK 正文输入与校验闸门 + +含中文 / 日文 / 韩文的正文必须走本节的 insertText 方案 + 校验闸门;纯 ASCII 短文本可用 `type`,但**发布前校验闸门**与**发布后终验**对**所有正文**强制执行。 + +**1. 清空回填草稿(open compose 后必做)**:X 重新打开 compose 页可能回填上次草稿,直接 insertText 会叠成两份: + +```bash +camoufox-cli --session twitter --json eval '(function(){var el=document.querySelector("[data-testid=tweetTextarea_0]");if(!el){return "NO_BOX";}el.focus();var s=document.execCommand("selectAll",false);var d=document.execCommand("delete",false);return (s&&d)?"CLEARED":"CLEAR_FAILED";})()' +``` + +**2. 插入正文**(CJK 用 insertText 整段插入;正文含单引号或反斜杠时先转义,避免破坏命令引号): + +```bash +camoufox-cli --session twitter --json eval '(function(){var el=document.querySelector("[data-testid=tweetTextarea_0]");if(!el){return "NO_BOX";}el.focus();var ok=document.execCommand("insertText",false,"<正文>");return ok?"INSERTED":"EXEC_FAILED";})()' +``` + +**3. 发布前校验闸门(强制——点击 Post / Reply 之前必须执行,非 MATCH 一律不发布)**: + +```bash +camoufox-cli --session twitter --json eval '(function(){var el=document.querySelector("[data-testid=tweetTextarea_0]");var t=el?el.innerText:"NO_BOX";var target="<正文>";return t===target?"MATCH":"MISMATCH:"+t;})()' +``` + +- **选择器必须精确匹配 `[data-testid=tweetTextarea_0]`**——`[data-testid^=tweetTextarea]` 前缀匹配会同时命中 `tweetTextarea_0_label`(占位符层),读到占位符文本、误报 MISMATCH。 +- **插入与校验必须分两次 eval 调用(间隔 ≥1s)**——写在同一 eval 里同步执行时,React 未及重渲染,innerText 会混入「What's happening?」占位符(占位符假警报)。MISMATCH 先看是否混有占位符再定性。 +- **emoji 是 `` 不是丢字**:✅ 等 emoji 在 DOM 里渲染为 ``,`innerText` 提取时只显示周围空格——校验按「剔除 img 节点后的文本」比对。 + +**4. 发布后终验(强化,所有发布流程共用)**:点 Post 后导航 profile 页,读最新推文 `[data-testid=tweetText]` innerText 与 status 链接,与目标正文比对(剔除 emoji img 因素)后才算发布成功;不符立即走删除流程(More → Delete → confirmationSheetConfirm)。 ### 字符计数规则(X 平台特殊) @@ -88,14 +116,16 @@ camoufox-cli --session twitter --persistent --headed --json open "https://x.com/ ``` 1. Navigate to https://x.com/compose/post 2. Wait for the compose box to load -3. Click into the text area and type the content +3. 按「通用约束 → CJK 正文输入与校验闸门」输入正文(CJK 走 insertText;纯 ASCII 可 type) - Plain text only (no Markdown) - Max 280 characters for standard accounts -4. Verify character count — trim if over limit -5. **立即点击 "Post" 按钮——不要等待用户确认!** -6. Wait for success confirmation (URL changes or "Your post was sent" toast) -7. Extract and report the post URL -8. **Parse stats**: +4. **发布前校验闸门**:eval 校验正文返回 MATCH(通用约束 step 3;非 MATCH 一律不发布) +5. Verify character count — trim if over limit +6. **立即点击 "Post" 按钮——不要等待用户确认!** +7. Wait for success confirmation (URL changes or "Your post was sent" toast) +8. **发布后终验**(通用约束 step 4):导航 profile 页比对最新推文正文,MATCH 才算发布成功;不符走删除重发 +9. Extract and report the post URL +10. **Parse stats**: - snapshot eval: `JSON.stringify({ retweet: document.querySelector('[data-testid="retweet"]')?.innerText, like: document.querySelector('[data-testid="like"]')?.innerText, @@ -103,7 +133,7 @@ camoufox-cli --session twitter --persistent --headed --json open "https://x.com/ view: document.querySelector('[href*="/analytics"]')?.innerText, permalink: window.location.href })` -9. Update frequency tracker +11. Update frequency tracker ``` --- @@ -115,7 +145,7 @@ camoufox-cli --session twitter --persistent --headed --json open "https://x.com/ 2. Wait for the compose box to load 3. Upload the image file using camoufox-cli upload(见下方选择器说明) 4. Wait for image upload to complete (thumbnail / "Media" group appears) -5. Click into the text area and type the caption +5. 按「通用约束 → CJK 正文输入与校验闸门」输入 caption(CJK 走 insertText);**发布前校验闸门**:eval 校验 MATCH 才点 Post(通用约束 step 3) - Plain text only (no Markdown) - Max 280 characters for standard accounts 6. **立即点击 "Post" 按钮——不要等待用户确认!** @@ -145,7 +175,7 @@ camoufox-cli --session twitter --persistent --json upload "[data-testid=fileInpu 2. Click the media icon 3. Upload the video file (MP4 recommended, max 512MB, max 2min 20sec) 4. Wait for video processing — this can take 30–120 seconds or more for larger files. Look for the thumbnail preview to confirm completion. -5. Click into the caption area and type the caption +5. 按「通用约束 → CJK 正文输入与校验闸门」输入 caption(CJK 走 insertText);**发布前校验闸门**:eval 校验 MATCH 才点 Post(通用约束 step 3) - Plain text only (no Markdown) - Max 280 characters for standard accounts 6. **立即点击 "Post" 按钮——不要等待用户确认!** @@ -160,11 +190,11 @@ camoufox-cli --session twitter --persistent --json upload "[data-testid=fileInpu ``` 1. Navigate to https://x.com/compose/post -2. Click into the compose box and type the first tweet +2. 按「通用约束 → CJK 正文输入与校验闸门」输入第一条推文(CJK 走 insertText) - Plain text only (no Markdown) - Max 280 characters for standard accounts 3. Click the "+" icon to add another tweet to the thread -4. Click into the new compose box and type the second tweet +4. 同上输入第二条推文(CJK 走 insertText),**发布前校验闸门**:每条 eval 校验 MATCH 才 Post all(通用约束 step 3) - Plain text only (no Markdown) - Max 280 characters for standard accounts 5. Repeat for each additional tweet @@ -185,7 +215,7 @@ camoufox-cli --session twitter --persistent --json upload "[data-testid=fileInpu - ⚠️ 区分 "Repost"(纯转推,无评论)vs "Quote"(引用+评论) 3. Compose box 打开,**已自动填入引用卡片** 4. Click into text area below the quoted card -5. Type your comment (max 280 chars) +5. 按「通用约束 → CJK 正文输入与校验闸门」输入评论(CJK 走 insertText);**发布前校验闸门**:eval 校验 MATCH(通用约束 step 3) 6. Verify character count 7. **立即点击 "Post" 按钮** 8. Wait for confirmation, report post URL @@ -206,7 +236,7 @@ camoufox-cli --session twitter --persistent --json upload "[data-testid=fileInpu 1. Navigate to source tweet URL(如 https://x.com/username/status/1234567890) 2. Click "Reply" icon(不是 reply 文本框) 3. Compose box 打开,**自动显示 reply context** -4. Type your reply (max 280 chars) +4. 按「通用约束 → CJK 正文输入与校验闸门」输入回复(CJK 走 insertText);**发布前校验闸门**:eval 校验 MATCH(通用约束 step 3) 5. Verify character count 6. **立即点击 "Reply" 按钮**(不是 "Post") 7. Wait for confirmation, report reply URL @@ -234,7 +264,7 @@ snapshot eval: document.querySelector('[data-testid="icon-verified"]') !== null ``` 1. Navigate to https://x.com/compose/post 2. Wait for compose box to load -3. Type content up to 25,000 chars +3. 按「通用约束 → CJK 正文输入与校验闸门」输入内容(CJK 走 insertText);**发布前校验闸门**:eval 校验 MATCH 才 Post all(通用约束 step 3) 4. **注意**:URL 仍 23 字符,Emoji 仍 2 字符 5. 按钮文字从 "Post" 变成 "**Post all**"(X 长帖是 1 个"post all"动作,但内容被服务端分页) 6. Click "Post all" @@ -325,7 +355,10 @@ snapshot eval: document.querySelector('[data-testid="icon-verified"]') !== null | Character limit exceeded (Premium 25K) | Trim or use thread | | Media upload fails | Retry once; check file format and size | | Upload strict mode violation (2 elements) | **用 `[data-testid=fileInput] >> nth=0` 消歧**(见 Workflow: Post with Image) | -| "Something went wrong, but don't fret" after Post | X 服务端瞬时错误。**优先精简正文**——这种情况大概率是字数超限(X 的字符计数规则与前端显示不完全一致,尤其 URL/emoji 计数偏差时实际超限但按钮未变灰)。先缩短正文再重试,而不是原样重发。重试流程:精简正文 → reload compose 页 → retype → reupload → re-click Post,最多 3 次。3 次仍失败才报告用户。 | +| "Something went wrong, but don't fret" after Post | **先过发布前校验闸门判因**:MISMATCH → 正文损坏/丢失,走清草稿 → insertText → MATCH 后重发;MATCH → X 服务端瞬时错误(实测与字数无关,第 3 次同内容重发成功),reload compose 页 → 清空回填草稿 → insertText → 复验 → re-click Post,**最多 3 次,每次重试必须重走校验闸门**。3 次仍失败才报告用户。仅当校验确认字数超限(URL=23 / emoji=2 计数偏差)时才精简正文。 | +| 重新 open compose 回填上次草稿 | 插入前先清空(focus → `execCommand("selectAll")` → `execCommand("delete")`),否则直接 insertText 会叠成两份 | +| 校验 innerText 混入 "What's happening?" 占位符 | 插入与校验必须**分两次 eval 调用(间隔 ≥1s)**——同次调用 React 未及重渲染会混入占位符;MISMATCH 先看是否混有占位符再定性 | +| 校验判读 emoji 缺失(✅ 变空格) | emoji 在 DOM 渲染为 ``,innerText 只显示周围空格——**不是丢字**;终验以 profile 页 `[data-testid=tweetText]` innerText + 时间线 `img` 节点共同判读 | | Rate limit error | **Wait 30 min minimum** (not 15) + check frequency tracker | | Post button greyed out | Content is empty or over limit — check before clicking | | Frequency tracker warns high-risk | Ask user: continue or defer to tomorrow? | diff --git a/crews/main/skills/expert-wx-channel/SKILL.md b/crews/main/skills/expert-wx-channel/SKILL.md index 2dc7c9ac..959f33d6 100644 --- a/crews/main/skills/expert-wx-channel/SKILL.md +++ b/crews/main/skills/expert-wx-channel/SKILL.md @@ -1,12 +1,12 @@ --- name: expert-wx-channel -description: 微信视频号运营专家。承接从定位起号、视频选题脚本、制作发布到数据复盘的完整运营工作。零散的发布、取数等操作也可以直接做。 +description: 微信视频号账号运营专家。承接定位起号、内容 DNA、选题与短标题/视频描述包装、已有素材轻加工、视频全案 Brief 与口播文案、发布与数据复盘;全片制作委托 content-producer。 metadata: openclaw: emoji: 📺 --- -# 微信视频号运营专家 +# 微信视频号账号运营专家 ## 预设 Workflow @@ -14,8 +14,8 @@ metadata: | 场景 | Workflow | 什么时候触发 | |------|----------|-------------| -| 内容 DNA 管理 | Style DNA | 建 / 更新内容 DNA(样本、偏好、局部借鉴、对标融合),决定样本落到哪个 DNA | -| 内容生产 | Content Production | 做一条 / 做几条视频号视频;输入可以是粗略想法、参考视频(仿照 / 同主题改写)或已有脚本草稿 | +| 内容 DNA 管理 | Style DNA | 建 / 更新内容 DNA(样本、偏好、局部借鉴、对标融合):先判作品类型,再决定样本落到哪个 DNA | +| 内容生产 | Content Production | 做一条 / 做几条视频号内容;main 直接做已有素材轻加工,视频全案只产出Brief并委托content-producer | | 起号与定位 | Account Setup | 新号起号、定位梳理、内容支柱搭建、冷启动方案、老号接手与诊断 | | 账号对标 | Account Benchmark | 对标账号 / 对标视频分析,并与默认或指定 DNA 逐项比较 | | 改稿与调整 | Editing | 改脚本、润色口播、换钩子、换风格、换封面、压缩时长 | @@ -37,20 +37,22 @@ metadata: | 工具 | 用途 | 命令 | |------|------|------| -| `wx-channel-style-profiler` | 生成单条视频 16 维 DNA report(维度 v0),并聚合 DNA 文档与 DNA template | `wx-channel-style-profiler` | +| `wx-channel-style-profiler` | 生成单条视频的 DNA report,并聚合 DNA 文档与 template(= Brief + 口播文案模板) | `wx-channel-style-profiler` | | `wechat-channels-publish` | 发布视频到视频号创作者中心(camoufox-cli 持久化 session `wechat-channel`) | 无 wrapper,按工具说明驱动 `camoufox-cli` | | `wx-channel-engagement` | 视频号助手后台作品数据抓取,写入 published-track 的 `pub_wx_channel` 表 | `wx-channel-engagement` | 跨领域通用技能:`published-track`(发布记录与指标库)、`content-calibrator`(DNA 表现评估)、`smart-search`(跨平台搜索,选题调研优先社交平台)、`council`(定位决策辅助)、`siliconflow-img-gen`(封面图生成)。 -视频制作链路(内容生产时按需编排,不属于本专家包):`content-producer` subagent(从脚本端到端制作成片)、`video-edit`(已有素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`viral-chaser`(抖音/B站/小红书视频追爆拆解)。 +素材加工相关技能:`video-edit`(素材加工拼接)、`talking-head-cut`(口播轻剪辑)、`ui-demo`(产品操作录屏)、`video-review`(成片质检闸门)、`siliconflow-img-gen`(封面图)、`pexels-footage` / `pixabay-footage`(免版权素材)。 + +**视频全案分工硬边界**:main 负责选题策划、按 DNA 出 **Brief**、拟定标题与简介、准备素材(用户素材预处理 / `ui-demo` 录屏 / 从 `campaign_assets/` 挑选,绝对路径写进 Brief)、监督推动 CP、成片后的发布与运营,也直接做图文内容与已有素材轻加工;视频全案的成片制作委托 `content-producer`。口播类视频的口播文案由 main 按 `narration-script` 子模块写好并随 Brief 交付(真人口播时,指导用户录音并取得录音文件),CP 不重写策略文案。Brief 指定 `workflow` 时 CP 必须采用;未指定时 CP 按其通用制作流程做(那是 CP 的基准准则,不是备选 workflow),档位由 Stage 1 定。Brief **不含 DNA 信息**、**不写实现路径**(参考脚本、引擎参数之类实现手段归 CP,规则详见 Content Production Workflow 的 Brief 硬性规则),main 也不替 CP 建工作区(双方 T3 权限可互访取文件)。 ## 平台速查 - 视频号核心引擎是**社交推荐 > 算法推荐**:分享(转发朋友圈/群聊)权重高于点赞;判断内容健康度交叉看「完播 × 分享」。 -- 视频号作品**没有标题概念**:描述文案(≤300 字,含 hashtag)就是作品展示文本;`published-track record --platform wx_channel --title` 必须传完整描述文案,不要传短标题。 +- 视频号发布页可同时填 **视频描述**(≤300 字,含 hashtag)与 **短标题**,官方称填短标题能获得更多流量:**两项都必须有、发布时都必须填**,都由 main agent 拟定。但作品管理页不展示短标题,所以取数、`wx-channel-engagement` 匹配与 `published-track record --platform wx_channel --title` 一律只用完整视频描述(`--title` 只是数据库字段名),**短标题不入库**。 - 发布与取数共用持久化 session `wechat-channel`(fail-first 队列):读到「session 正忙」就等当前操作完成再重试,不自动 close。 -- 前 3 秒决定去留:封面三要素(身份 + 痛点 + 解决方案),前 2 秒抛冲突,第 3 秒预告价值。 +- 前 3 秒决定去留:身份 + 痛点 + 解决方案,前 2 秒抛冲突,第 3 秒预告价值。 - 真人出镜占比建议 ≥ 60%;起号期前 5 条必须垂直打透一个定位,周更 3-5 条。 - 冷启动只发动真实私域(点赞-评论-转发三连),禁止买量、互刷、群控、诱导互动(「点赞关注才发」类话术)。 - 除自己账号外没有公开抓取路径:对标样本的文案与数据必须用户提供,不得编造。 @@ -58,7 +60,9 @@ metadata: ## 风格与 DNA -账号内容风格 DNA 存储目录是 `wx_channel/dna/`。未指定 DNA 时默认使用并更新 `dna-0`。生产前同时读取 DNA 文档与 DNA template;对标分析先建立独立对标 DNA,不默认写入 `dna-0`。 +DNA 存储目录是 `wx_channel/dna/`。未指定 DNA 时默认使用并更新 `dna-0`。生产前同时读取 DNA 文档与 DNA template;对标分析先建立独立对标 DNA,不默认写入 `dna-0`。 + +DNA 是**从一批作品样本提取并聚合出的内容生产规则集**:10 维——选题与观看理由、短标题与视频描述与封面、内容创意、**业务植入套路**、**互动引导与 CTA 套路**、视频内容形态与制作指向、制作规格与视听倾向,加可选的口播文案子模块与账号运营子模块(简介写法、发布习惯)。它指导 main agent 出 Brief(+ 口播文案)与发布文案,不规定创作细节与成片制作。维度框架 v2 位于 `wx-channel-style-profiler` 的 `references/video-dna-framework.md`。 ## 数据与记录 diff --git a/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md b/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md index a1fda7ad..0aca54fd 100644 --- a/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md +++ b/crews/main/skills/expert-wx-channel/tools/wechat-channels-publish/SKILL.md @@ -1,17 +1,21 @@ --- name: wechat-channels-publish -description: 通过 camoufox-cli 持久化 session wechat-channel 发布视频到微信视频号,支持视频上传、标题描述填写、即时发布。 +description: 通过 camoufox-cli 持久化 session wechat-channel 发布视频到微信视频号,支持视频上传、封面上传、视频描述与短标题填写、即时发布。 --- # wechat-channels-publish — 工具说明 > 本文是 `expert-wx-channel` 专家包内的工具说明书,不独立出现在技能列表中。由相关 Workflow 指引调用。 -通过 **camoufox-cli** 持久化 session `wechat-channel`(有且只有一个,fail-first 队列:同 session 已有命令在跑时新命令直接 fail)在微信视频号创作者中心发布视频。视频号创作者中心使用 **wujie 微前端**,所有表单元素在 `::shadow-root` 内——camoufox-cli 的 `snapshot` 默认穿透 shadow DOM 拿 ref,后续 `click` / `type` / `upload` 按 ref 操作即可,无需 CDP hack。 +通过 **camoufox-cli** 持久化 session `wechat-channel`(有且只有一个,fail-first 队列:同 session 已有命令在跑时新命令直接 fail)在微信视频号创作者中心发布视频。视频号创作者中心使用 **wujie 微前端**,所有表单元素在 `::shadow-root` 内。 -**输入**:本地视频文件(`.mp4` / `.mov` / `.avi` / `.webm`)、短标题(6-16 字,最长约 30 字)、描述文案(含话题标签,最长约 300 字)。 +**业务页 `snapshot` 一律加 `-s "wujie-app"` 作用域**(下文简写 `snapshot -s`):wujie 页面里主文档与子应用各有一个 body,整页 `snapshot` 会报 `locator('body') resolved to 2 elements` strict violation,拿不到任何 ref。只有登录页(无 wujie)可整页 snapshot。`click` / `fill` / `type` 按 ref 操作;`upload` 支持 ref 或 CSS 选择器(底层 Playwright setInputFiles,穿透 shadow DOM),无需 CDP hack。 + +**输入**:本地视频文件(`.mp4` / `.mov` / `.avi` / `.webm`)、**视频描述**(含话题标签,最长约 300 字)、**短标题**(6-16 字)。发布页改版后两项都能填,官方明确「填写短标题会获得更多流量」,因此**两项都必须填**,都由 main agent 拟定后交给本工具。 **输出**:视频号已发布作品;能取到时附带公开链接(`https://weixin.qq.com/sph/xxxx`)。 +> **短标题只在发布页存在**:作品管理页与 `wx-channel-engagement` 抓取都拿不到短标题,所以入库与匹配一律只用视频描述(见文末「入库衔接约束」)。 + > **主力后端 = `target=camoufox`**。下方命令 / 示例只针对 `target=camoufox`。 > **`target=host` / `target=node`**:只按本说明书的「流程 + 提示事项」走——全部无头 / 频率限制 / 错误处理约定是**后端无关**的,照本说明书执行。不要照搬 `camoufox-cli ...` 命令,用你当前后端自带的浏览器工具语义调用即可。 @@ -44,90 +48,137 @@ camoufox-cli --session wechat-channel --persistent --json open "https://channels ### Step 2: 检查登录态 -`snapshot` 看页面 URL 是否含 `login` 或出现登录二维码——命中走前置条件的无头截图扫码登录流。 +``` +camoufox-cli --session wechat-channel --json url +``` + +URL 含 `login` → 走前置条件的无头截图扫码登录流。需要看页面内容时:登录页可整页 `snapshot`;发布页(wujie 已加载)必须 `snapshot -s "wujie-app"`。 ### Step 3: 上传视频 ``` -1. snapshot 拿到上传触发按钮 ref(shadow DOM 内的 span.add-icon 或 div.upload-content) -2. camoufox-cli --session wechat-channel --persistent --json click <上传触发-ref> -3. snapshot 拿到弹出的 ref -4. camoufox-cli --session wechat-channel --persistent --json upload - - camoufox-cli upload 命令底层走 Playwright setInputFiles,穿透 shadow DOM,无需 CDP setFileInput / base64 hack +camoufox-cli --session wechat-channel --persistent --json upload "input[type=file]" ``` +- 发布页常驻一个隐藏 ``,**直接用选择器 upload,不要先 click 上传按钮**——click 触发按钮后 snapshot 里并不会出现 file input ref,「click → snapshot 拿 ref → upload ref」路线走不通。 +- 此时页面只有这一个 file input,裸选择器 `input[type=file]` 唯一命中。**封面弹窗打开后页面会有两个 file input**,裸选择器 strict violation——视频上传必须在打开封面弹窗之前完成(封面见 Step 5)。 + ### Step 4: 等待上传+转码完成 -每 3 秒 `snapshot` 检查一次页面状态: +每 3 秒 `snapshot -s "wujie-app"` 检查一次页面状态: - 上传中:shadow DOM 内存在 `[class*="uploading"]` 或 `[class*="progress"]` - 转码中:`[class*="transcoding"]` - 完成:出现 `