From ac6daaf1f9d27370bd1ee90fe0a01248c826d44f Mon Sep 17 00:00:00 2001 From: Menglei Li <57990306+Lumos-789@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:49:11 +0800 Subject: [PATCH] feat(delivery-video-plugin): add delivery-video-plugin plugin v1.1.2 --- assets/delivery-video/icon.png | Bin 0 -> 3168 bytes .../delivery-video/.codex-plugin/plugin.json | 33 + .../delivery-video/.zcode-plugin/plugin.json | 27 + external_plugins/delivery-video/CHANGELOG.md | 30 + external_plugins/delivery-video/LICENSE | 21 + external_plugins/delivery-video/README.md | 76 ++ external_plugins/delivery-video/README_CN.md | 74 ++ .../assets/delivery-video/icon.png | Bin 0 -> 3168 bytes .../delivery-video/cordis.patch.yml | 3 + .../delivery-video/examples/cases.example.md | 17 + .../examples/episode.example.json | 37 + .../examples/feedback-episode.example.json | 39 + external_plugins/delivery-video/index.js | 89 ++ .../skills/delivery-video/SKILL.md | 225 +++++ .../references/episode-and-review.md | 197 ++++ .../delivery-video/references/recording.md | 52 + .../delivery-video/scripts/preflight.py | 509 ++++++++++ .../skills/delivery-video/scripts/render.py | 893 ++++++++++++++++++ .../delivery-video/scripts/verify_delivery.py | 418 ++++++++ marketplace.json | 93 +- 20 files changed, 2820 insertions(+), 13 deletions(-) create mode 100644 assets/delivery-video/icon.png create mode 100644 external_plugins/delivery-video/.codex-plugin/plugin.json create mode 100644 external_plugins/delivery-video/.zcode-plugin/plugin.json create mode 100644 external_plugins/delivery-video/CHANGELOG.md create mode 100644 external_plugins/delivery-video/LICENSE create mode 100644 external_plugins/delivery-video/README.md create mode 100644 external_plugins/delivery-video/README_CN.md create mode 100644 external_plugins/delivery-video/assets/delivery-video/icon.png create mode 100644 external_plugins/delivery-video/cordis.patch.yml create mode 100644 external_plugins/delivery-video/examples/cases.example.md create mode 100644 external_plugins/delivery-video/examples/episode.example.json create mode 100644 external_plugins/delivery-video/examples/feedback-episode.example.json create mode 100644 external_plugins/delivery-video/index.js create mode 100644 external_plugins/delivery-video/skills/delivery-video/SKILL.md create mode 100644 external_plugins/delivery-video/skills/delivery-video/references/episode-and-review.md create mode 100644 external_plugins/delivery-video/skills/delivery-video/references/recording.md create mode 100644 external_plugins/delivery-video/skills/delivery-video/scripts/preflight.py create mode 100644 external_plugins/delivery-video/skills/delivery-video/scripts/render.py create mode 100644 external_plugins/delivery-video/skills/delivery-video/scripts/verify_delivery.py diff --git a/assets/delivery-video/icon.png b/assets/delivery-video/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f76223404b3b84d3c397e3b6ecf326f917e93108 GIT binary patch literal 3168 zcmeHK`&Uy}7Tz~pV}PUyXnCt3QXF~3AP_7Vb;z zB5ViE$jS%+Xu&%JzW^W+LlRJahcv5zn*dbD;K1$wNUfN9?whn>zzRD=ZosyWhA7Fs zI|H^j`4x<~Ficl9P}j%!-xc&fJ@_aPwfFQBEFA~D-_+CpX6yuJzR9vdCw#ovp0J{4IBfs)XaU4rLU+I^#D4CXDv zw;n8nU1*#?wsrUrR`atb3KrQ<6%GmDcw+a-l(A@K(%kVLGQ3M%WIkre@Mr~1PqKK-p+`>ZOR?EaX34oKkCf6UZ0Sj&W zdcd!DY0sJh-&?BhMS<5cA;AE~H*0F_01xicswpsoN=17~;L|Z7DF;|!YK&1>yd;+p zXV-~!wg|RW%IRE}Iletwv~YI1dw6-xJlh(=y;L>RirqSz^*v;JV&G5Jkd`;vlEPj#+ymgxVfsz&~R~Vxx#^ ze;kkFp4obhF{(oMV zN-5y;WFjGi1a#IyX*uA&VxbWVy0(d$08oW?Jr%48eS} z=4CUAWr~IDt%xd-%jwz<>_R!czyz+v&-J|0-f#M&es?p8{BP#!uZ}3v(q`!)jD-&) zbnHZ_u0Kuxe(D&NVhT43(wz~|*&1x`228_m{TiD3;aFdh8WHeT!IwY=!Y7jHtF8Dm93xz{j|41ya$v8CF@$yQ(+!)E`pZ z%O3))GLkaC9vnZ6W=O5|Rz>MkhQMT44qkD`+}Vbzsy2~Yq%L^h96gqk2zawIN_aG3 z#`LUxnd)9)x|0buoP6PG=`EfmuhJcJuptX5?tj-iO0tqg4f|pY;FHJx0&j%Za+KC) zE)O}ZGEG8U#<>jT#a$xT4N!aq$%>gv-r9t@zE-!fqb^qj!CqBlJ06!j| z|9;M^JBzca(GllQqbCUR1oJRG5!%$(-??2In};K&SmnWA-!4hk)gt&L%GXO94}ZDz zeQqDDeYvsZKB~%FEBW;g8L|YLj)!UeqQdY+WE>%yi-?nkRa4Q#`p z&rQ0eivRGJqC1NPd7-=JLkF}jk0UL$KHX$lYg~_08h-V&`yq0&H69edf5khbpQ-WA zhq~At(!jklAg?fwX;2u$2H0=DCRg^xmeBV3WU^WPlmm;hgd=e)%Hg+FdP7O ilRm=7.3.1" Pillow` + +`edge-tts` 7.2.8 and older can return HTTP 403 from the upstream endpoint. Use 7.3.1 or newer; the renderer fails early with an upgrade command when the installed version is too old. +Run `preflight.py`, `render.py` and `verify_delivery.py` with the same Python interpreter. Preflight fails early when ffmpeg/ffprobe, Pillow, a supported edge-tts version or a CJK font is missing. + +Platform setup: + +- macOS: `brew install ffmpeg`, then `python3 -m pip install "edge-tts>=7.3.1" Pillow` +- Windows: install FFmpeg with `winget install --id Gyan.FFmpeg -e` (or `choco install ffmpeg`). If `python3` is not available, use `py -3` consistently and run `py -3 -m pip install "edge-tts>=7.3.1" Pillow` +- Linux: install `ffmpeg` and a CJK font such as `fonts-noto-cjk` with your package manager, then install the Python packages + +The renderer needs a CJK font for Chinese labels and subtitles. macOS and Windows normally provide one; minimal Linux images often do not, and preflight reports the missing font before rendering. + +## Side effects (disclosed for review) + +- **Network**: narration synthesis calls the public edge-tts (Microsoft Cognitive Services) endpoint; no other network access. +- **Files**: writes only inside the build directory you pass in (`tts/` cache, per-segment clips, final mp4, gate reports). +- **Commands**: runs `ffmpeg`/`ffprobe` locally; the skill never uploads, publishes or deletes anything by itself. + +## Quick start + +Install: `dsh plugin add /path/to/delivery-video-plugin` (DSH bundle). It exposes the skill in `skills/delivery-video/`. + +1. Record real takes of the feature with your usual tool (macOS `Cmd+Shift+5`, OBS, or a scripted browser screencast — see the skill's recording reference). For two or more acceptance cases, list them first using `examples/cases.example.md` as a template, then record one real run per case. +2. Create `build/screen_demo-/episode.json` (copy `examples/episode.example.json`). +3. Run the three scripts (use `py -3` instead of `python3` on Windows when needed): + +```bash +python3 /scripts/preflight.py build/screen_demo-myfeature +python3 /scripts/render.py render build/screen_demo-myfeature +python3 /scripts/verify_delivery.py build/screen_demo-myfeature +``` + +Run the unit tests with: + +```bash +python3 -m unittest discover -s tests -v +``` + +Suggested trial prompt after install: *"用 delivery-video 把这个项目刚完成的功能录成一段 90 秒交付演示视频"* — the skill will walk verification, storyboard, recording guidance, recipe assembly, render and gating. + +## Same-name skill copies + +ZCode 0.17.x can keep a user-level skill and a plugin skill with the same name. If an older `~/.agents/skills/delivery-video` still exists, the unqualified `/skill delivery-video` command may select that copy. Select `delivery-video:delivery-video` in the composer, or use `/skill delivery-video:delivery-video `, to run the installed plugin. + +## License + +MIT © Lumos-789 diff --git a/external_plugins/delivery-video/README_CN.md b/external_plugins/delivery-video/README_CN.md new file mode 100644 index 00000000..c8337b41 --- /dev/null +++ b/external_plugins/delivery-video/README_CN.md @@ -0,0 +1,74 @@ +# delivery-video + +把**真实运行**的功能做成可信交付演示视频的 skill 插件——真实录屏、合成旁白、逐字对齐字幕,加一道拒绝放过造假或残次演示的确定性质量闸。 + +大多数「AI 演示视频」是静态截图、mock 页面或旧录屏拼出来的。这个插件强制相反的纪律:每个镜头必须指向一段真实录屏和可追溯的时间区间,最后一镜必须落在已回读验证的结果状态上,成片必须先过硬检查(分辨率/帧率/编码、音轨、响度、黑帧、冻结帧)才允许外发。码率密度会作为信息项保留,关键页面仍需按 1:1 目检。贴出去的文件永远是过闸的那一个文件,用 SHA256 绑定。 + +在 DeepSeek Harness 中,该 Skill 会同时注册到 Host 与每个 Agent 作用域。因此即使 Agent preset 在更近的文件系统层加载了 Skill,已安装插件仍会优先于 `~/.agents/skills` 里的旧同名副本。 + +## 它做什么 + +1. **先验真,再录**——工作流要求先有当前版本真实跑通的版本证据(git SHA、工单、请求 ID),才允许录制。 +2. **按用例组织(推荐)**——多 case 交付时,建议先拆一张用例清单,再一个 case 一次真实运行录制,最后按执行顺序拼镜头,每个 case 以自己的结果回读收尾。属使用建议,不做强制校验。 +3. **episode.json 配方**——每个镜头绑定一段真实原片 + 时间区间 + 一句旁白 + 可选裁切/case 徽标。 + 写 `intent: "feedback"` 并添加 `issue` 块即可产出问题反馈视频:严重级别与阶段徽标、 + 复现步骤浮层、复现/现象镜头,以及 `feedback.json` 和可直接贴工单的 + `feedback.generated.md`。闸报告同时绑定成片 SHA256、`feedback.json` 字段的归一化哈希 + 与 markdown 原始字节哈希,渲染后改动报告内容或删除/篡改 markdown 都会被 + `verify_delivery.py` 拒绝;质量闸规则不变。可从 `examples/feedback-episode.example.json` 复制起步。 +4. **自包含渲染器**——`render.py` 用 edge-tts 合成旁白(逐字时间钟),按旁白时长配速每个镜头(0.3x–3x 变速硬校验,超范围直接失败,绝不自动钳制),绘制标签徽标与逐字同步字幕,拼接、响度归一到 −14 LUFS,并自动跑内置质量闸。输出:`screen_demo.mp4`(1920×1080、30fps、h264,音轨为 AAC——非 AAC 只会得到闸警告 `E102`,不算失败)、`timeline.json`、封面/预览帧、`quality_gate/report.json`。 +5. **Fail-loud 检查**——`preflight.py` 渲染前校验配方(结构、区间、裁切、浏览器录屏证据、TTS 缓存契约、旁白红线);`verify_delivery.py` 渲染后核验成片、闸绑定与可选的独立视觉复核。 +6. **附件闸**——`render.py gate` 对任意 mp4 跑同一套硬检查,贴进工单、群聊或客户渠道之前必须过。 + +## 前置依赖 + +- `python3` 3.9+ +- PATH 里有 `ffmpeg` 和 `ffprobe` +- `pip install "edge-tts>=7.3.1" Pillow` + +`edge-tts` 7.2.8 及更早版本会从上游端点收到 HTTP 403。请使用 7.3.1 或更新版本;版本过低时渲染器会直接失败并给出升级命令。 +`preflight.py`、`render.py` 和 `verify_delivery.py` 要用同一个 Python 解释器运行。预检会在 ffmpeg/ffprobe、Pillow、edge-tts 版本或中文字体不满足要求时直接失败。 + +不同系统的准备方式: + +- macOS:`brew install ffmpeg`,然后执行 `python3 -m pip install "edge-tts>=7.3.1" Pillow` +- Windows:用 `winget install --id Gyan.FFmpeg -e`(或 `choco install ffmpeg`)安装 FFmpeg;如果没有 `python3`,统一改用 `py -3`,并执行 `py -3 -m pip install "edge-tts>=7.3.1" Pillow` +- Linux:用包管理器安装 `ffmpeg` 和中文字体(例如 `fonts-noto-cjk`),再安装 Python 包 + +渲染器需要中文字体来绘制标签和字幕。macOS 和 Windows 通常自带;精简 Linux 镜像可能没有,预检会在渲染前直接报告字体缺失。 + +## 副作用披露(审核要求如实说明) + +- **网络**:旁白合成调用公共 edge-tts(Microsoft 认知服务)端点;无其他网络访问。 +- **文件**:只在你传入的构建目录内写入(`tts/` 缓存、逐镜头片段、成片、闸报告)。 +- **命令**:本地执行 `ffmpeg`/`ffprobe`;本 skill 自身不上传、不发布、不删除任何东西。 + +## 快速开始 + +安装:`dsh plugin add /path/to/delivery-video-plugin`(DSH bundle),会暴露 `skills/delivery-video/` 下的 skill。 + +1. 用你惯用的工具录真实原片(macOS `Cmd+Shift+5`、OBS,或脚本化浏览器录屏——见 skill 内录制指引)。覆盖两条以上验收用例时,先按 `examples/cases.example.md` 拆清单,再一个 case 录一次真实运行。 +2. 创建 `build/screen_demo-/episode.json`(可复制 `examples/episode.example.json`)。 +3. 依次跑三个脚本(Windows 没有 `python3` 时改用 `py -3`): + +```bash +python3 /scripts/preflight.py build/screen_demo-myfeature +python3 /scripts/render.py render build/screen_demo-myfeature +python3 /scripts/verify_delivery.py build/screen_demo-myfeature +``` + +单测命令: + +```bash +python3 -m unittest discover -s tests -v +``` + +安装后的试用 prompt:*"用 delivery-video 把这个项目刚完成的功能录成一段 90 秒交付演示视频"*——skill 会带你走完验真、分镜、录制指引、配方组装、渲染与过闸。 + +## 同名 Skill 副本 + +ZCode 0.17.x 允许用户级 Skill 和插件 Skill 同名共存。如果本机还保留旧的 `~/.agents/skills/delivery-video`,直接输入 `/skill delivery-video` 可能选中旧副本。请在输入框选择 `delivery-video:delivery-video`,或使用 `/skill delivery-video:delivery-video <任务>` 调用已安装插件。 + +## 许可 + +MIT © Lumos-789 diff --git a/external_plugins/delivery-video/assets/delivery-video/icon.png b/external_plugins/delivery-video/assets/delivery-video/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f76223404b3b84d3c397e3b6ecf326f917e93108 GIT binary patch literal 3168 zcmeHK`&Uy}7Tz~pV}PUyXnCt3QXF~3AP_7Vb;z zB5ViE$jS%+Xu&%JzW^W+LlRJahcv5zn*dbD;K1$wNUfN9?whn>zzRD=ZosyWhA7Fs zI|H^j`4x<~Ficl9P}j%!-xc&fJ@_aPwfFQBEFA~D-_+CpX6yuJzR9vdCw#ovp0J{4IBfs)XaU4rLU+I^#D4CXDv zw;n8nU1*#?wsrUrR`atb3KrQ<6%GmDcw+a-l(A@K(%kVLGQ3M%WIkre@Mr~1PqKK-p+`>ZOR?EaX34oKkCf6UZ0Sj&W zdcd!DY0sJh-&?BhMS<5cA;AE~H*0F_01xicswpsoN=17~;L|Z7DF;|!YK&1>yd;+p zXV-~!wg|RW%IRE}Iletwv~YI1dw6-xJlh(=y;L>RirqSz^*v;JV&G5Jkd`;vlEPj#+ymgxVfsz&~R~Vxx#^ ze;kkFp4obhF{(oMV zN-5y;WFjGi1a#IyX*uA&VxbWVy0(d$08oW?Jr%48eS} z=4CUAWr~IDt%xd-%jwz<>_R!czyz+v&-J|0-f#M&es?p8{BP#!uZ}3v(q`!)jD-&) zbnHZ_u0Kuxe(D&NVhT43(wz~|*&1x`228_m{TiD3;aFdh8WHeT!IwY=!Y7jHtF8Dm93xz{j|41ya$v8CF@$yQ(+!)E`pZ z%O3))GLkaC9vnZ6W=O5|Rz>MkhQMT44qkD`+}Vbzsy2~Yq%L^h96gqk2zawIN_aG3 z#`LUxnd)9)x|0buoP6PG=`EfmuhJcJuptX5?tj-iO0tqg4f|pY;FHJx0&j%Za+KC) zE)O}ZGEG8U#<>jT#a$xT4N!aq$%>gv-r9t@zE-!fqb^qj!CqBlJ06!j| z|9;M^JBzca(GllQqbCUR1oJRG5!%$(-??2In};K&SmnWA-!4hk)gt&L%GXO94}ZDz zeQqDDeYvsZKB~%FEBW;g8L|YLj)!UeqQdY+WE>%yi-?nkRa4Q#`p z&rQ0eivRGJqC1NPd7-=JLkF}jk0UL$KHX$lYg~_08h-V&`yq0&H69edf5khbpQ-WA zhq~At(!jklAg?fwX;2u$2H0=DCRg^xmeBV3WU^WPlmm;hgd=e)%Hg+FdP7O ilRm", + "expected": "The list shows the new total right after saving.", + "actual": "The list still shows the old total until the page is fully reloaded.", + "repro": [ + "Open the order detail page", + "Change the amount to 1200 and save", + "Return to the order list and read the total" + ] + }, + "segments": [ + { + "id": "01-repro", + "phase": "repro", + "label": "复现", + "shot": "take-01.mp4", + "start": 0.5, + "end": 12.0, + "text": "照着三个步骤操作,保存后返回列表。" + }, + { + "id": "02-actual", + "phase": "actual", + "label": "现象", + "shot": "take-02.mp4", + "start": 0.5, + "end": 9.0, + "text": "列表这里仍然是旧总额,和刚才保存的数值对不上。" + } + ] +} diff --git a/external_plugins/delivery-video/index.js b/external_plugins/delivery-video/index.js new file mode 100644 index 00000000..4d1b7025 --- /dev/null +++ b/external_plugins/delivery-video/index.js @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +const skillRoot = fileURLToPath(new URL("./skills/delivery-video/", import.meta.url)); +const skillPath = join(skillRoot, "SKILL.md"); + +function readFlag(value, field) { + if (value === undefined) return false; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (["true", "yes", "on", "1"].includes(normalized)) return true; + if (["false", "no", "off", "0"].includes(normalized)) return false; + } + throw new Error(`delivery-video: ${field} must be a boolean`); +} + +function loadSkill() { + const source = readFileSync(skillPath, "utf8"); + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source); + if (frontmatter === null) { + throw new Error(`delivery-video: missing YAML frontmatter in ${skillPath}`); + } + const header = parseYaml(frontmatter[1]); + if ( + header === null || + typeof header !== "object" || + typeof header.name !== "string" || + typeof header.description !== "string" + ) { + throw new Error(`delivery-video: invalid skill metadata in ${skillPath}`); + } + return { + name: header.name, + description: header.description, + ...(typeof header.whenToUse === "string" ? { whenToUse: header.whenToUse } : {}), + ...(header.metadata && typeof header.metadata === "object" ? { metadata: header.metadata } : {}), + invocation: { + modelInvocable: !readFlag(header["disable-model-invocation"], "disable-model-invocation"), + userInvocable: !readFlag(header["user-invocable"], "user-invocable"), + }, + source: "bundled", + path: skillPath, + content: source.slice(frontmatter[0].length).trimStart(), + resourceBase: { kind: "directory", path: skillRoot }, + }; +} + +const skill = loadSkill(); + +export const name = "delivery-video"; +export const inject = ["skills", "agents"]; + +export function apply(ctx) { + ctx.skills.register(skill); + + const agentRegistrations = new Map(); + const registerForAgent = (agent) => { + if (agentRegistrations.has(agent)) return; + // DSH preset filesystem skills shadow host registrations, so mirror into the nearest Agent layer. + const injection = agent.ctx.inject(["skills"], (agentCtx) => { + agentCtx.skills.register(skill); + }); + agentRegistrations.set(agent, () => injection.dispose()); + }; + + for (const agent of ctx.agents.list()) { + registerForAgent(agent); + } + + ctx.on("agent/created", ({ agent }) => { + registerForAgent(agent); + }); + ctx.on("agent/disposed", ({ agent }) => { + agentRegistrations.get(agent)?.(); + agentRegistrations.delete(agent); + }); + + ctx.effect( + () => () => { + const disposals = [...agentRegistrations.values()].map((dispose) => dispose()); + agentRegistrations.clear(); + return Promise.all(disposals); + }, + "delivery-video agent registrations", + ); +} diff --git a/external_plugins/delivery-video/skills/delivery-video/SKILL.md b/external_plugins/delivery-video/skills/delivery-video/SKILL.md new file mode 100644 index 00000000..cfe7cff7 --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/SKILL.md @@ -0,0 +1,225 @@ +--- +name: delivery-video +description: Produce trustworthy delivery/acceptance demo videos — or defect/feedback report videos — from real screen recordings with narration, subtitles and a deterministic quality gate. Use when the user says "record a delivery video", "make a feature demo", "acceptance demo video", "bug report video", "record the defect", "录交付视频", "录个演示", "功能演示", "验收演示视频", "问题反馈视频", "缺陷复现视频", "把 bug 录下来". Renders real recordings only — no static screenshots, mocks or old footage stitched into a video. +metadata: + short-description: Record, render and gate trustworthy delivery demo videos +--- + +# Delivery Video + +Turn a feature that actually runs into a reviewable delivery video. The video proves only what really happened on screen: never stitch static screenshots, mocks, stale recordings or test logs into a "demo". + +## Prerequisites + +The skill ships its own pipeline in `scripts/` next to this file (locate them relative to this SKILL.md): + +- `render.py` — render episode.json into `screen_demo.mp4` + run the built-in quality gate (`render` / `gate` subcommands) +- `preflight.py` — fail-loud pre-render check of the build directory +- `verify_delivery.py` — verify the finished artifact and its evidence chain + +Requires on the host: `python3` (3.9+), `ffmpeg`/`ffprobe` on PATH, and `pip install "edge-tts>=7.3.1" Pillow` (narration synthesis + frame drawing). edge-tts 7.2.8 and older can return HTTP 403 from the upstream service. If any prerequisite is missing, stop and report it — do not silently fall back to a weaker path. + +Use the same Python interpreter for preflight, render and verification. Preflight checks the runtime dependencies before touching the recording. +On Windows, use `py -3` instead of `python3` when that is the installed launcher. The renderer also needs a CJK font; minimal Linux hosts may need `fonts-noto-cjk`. + +## Completion criteria + +A delivery is done only when all of these hold: + +- Evidence the current version really runs: git SHA, deployed or local version, real test ticket/account/request IDs. +- Every segment in `episode.json` points at a real recording with a traceable time range. +- The artifact is rendered by `render.py`: 1920x1080, 30fps, h264 with narration and burned subtitles. AAC is what the renderer produces, but a non-AAC audio track is only a gate warning (`E102`), not a hard failure. +- The last segment ends on a **result state** — a title, data or success message visible and read back, never on a waiting spinner or a black frame. +- `quality_gate/report.json` has `status: "pass"` and empty `hard_failures`; never loosen thresholds to pass. +- Key frames have been visually inspected; for external deliveries an independent `visual-critic.json` review is required (`verify_delivery.py --require-critic`). +- Anything posted to a ticket, chat or customer channel was gated first (`render.py gate`), and **the uploaded file is the gated file** (matching SHA256). +- A `delivery.md` notes version, evidence, absolute artifact path, SHA256, the actual commands, and the "not yet uploaded/published" status. + +A passing gate is not the user's acceptance. Posting to a ticket or group is a delivery action — do it only when the user asks in that turn; public upload or publishing waits for an explicit "send". + +For an `intent: "feedback"` episode the same evidence rules apply, but the result state is the **defect still being reproduced**: the last segment must show the wrong behaviour or its explicit impact, never a fix that was not actually proven. + +## Workflow + +### 1. Define what this demo must prove + +Confirm from the request and the target project: + +- Audience: internal stakeholder, customer, or engineering team. +- The 2–5 key results the demo must cover. +- Environment, version, test accounts/data that can be reproduced. +- Sensitivity: customer names, contacts, internal URLs, credentials, debug panels that must be cropped out. +- Where the artifact lands. + +When missing information would materially change the shots or wording, ask once; otherwise use conservative defaults: internal delivery, 60–120s, narration in the user's language, real test environment, landscape 1920x1080. + +When the feature already has acceptance cases (a test-case list, a ticket checklist, a set of regression scenarios), it is recommended to write them down before recording — one row per case: case id, what it proves, entry, precondition/test data, and the expected observable result. Keep the list in the target project, beside the build directory (`cases.md`) or in `delivery.md`. Project-specific notes stay with that project; the plugin package itself stays self-contained. This is guidance, not a gate: a single happy-path demo can skip it, but once the video covers two or more distinct flows the list is what keeps takes, narration and the final claims aligned. Do not invent cases to fill the list; use the project's real acceptance items or the user's own list. + +### 2. Verify first, record second + +Before recording, the same version must be demonstrable live: + +- Record git state, target environment, deployed SHA or local start command. +- Run the core flow once for real; keep API/page/database evidence and ticket IDs. +- If the feature is broken, versions mismatch or data cannot be reproduced — stop and report the blocker; never pad with old footage. +- Numbers, names and states in the narration must come from that same real run; never fix numbers first and hunt for footage to match. + +### 3. Write the storyboard and narration + +One segment proves one result: + +| ID | case | visual | real action & verification | narration | +| --- | --- | --- | --- | --- | +| 01-entry | F-01 | feature entry | open the page, current version visible | one sentence: what this demo solves | +| 02-action | F-01 | key operation | real click/submit, save evidence | what the user did, what the system answered | +| 03-result | F-01 | result read-back | refresh/read back, result still there | the business outcome, not the internals | + +For external deliveries the narration states user-visible facts only — no "build", "API", "database", "pipeline", "deploy" jargon and no internal codenames. Technical acceptance may quote real commands and evidence, still exactly as run. One sentence carries one complete thought; avoid long embedded clauses. The renderer hard-fails one Chinese AI-cliche template — `这…不是…而是/是` (for example 「这不是慢,而是缓存没刷新」) — and warns on internal jargon. The English "this is not X, it's Y" shape is not matched, so this red line only bites Chinese narration. + +Give the video a short background opening. Before the first case, one segment — or one sentence in the first segment — should say what this feature is, who it is for and what problem this demo covers; later cases then only explain their own delta and expected result. Keep the background in the user's language and grounded in what is actually on screen. Recommended, not a required field, and the user decides how much context they want. + +### 4. Record the real screen + +Use the recording tool you have — see [references/recording.md](references/recording.md) for per-platform options (macOS screen recording, OBS, scripted browser screencast). Rules that hold regardless of tool: + +- One segment = one continuous real operation; the success feedback and read-back should land inside the same take when possible. +- Never overwrite old takes; new version = new filenames. +- Never substitute footage from a different run, and never cut pieces of different runs into one "success". +- Browser footage recorded by a scripted screencast tool should carry a `.evidence.json` beside it (sha256, size, duration, frames); mark those segments `source: "browser"` — preflight fails a browser segment without evidence. +- Dedicated test accounts and data; crop unrelated customer info, contacts, debug panels. +- If a take shows a long frozen frame, a blank screen, or captured notifications/privacy content — delete it and re-record. Fix the material, not the threshold. + +Recommended for multi-case deliveries: run and record one case at a time. A case should start from a known state and own its takes (for example `F-01-entry.mp4`, `F-02-result.mp4`), so the footage, the case id and the narration all point at the same run. Reset or prepare test data between cases instead of inheriting the previous case's result state, unless the cases genuinely share one continuous flow. Re-record a bad take in full; never splice half of one run with another to make a case look complete. Keep takes and the case list inside the target project's own media/build location, not inside the skill or plugin directory. + +Track the outcome per case while recording: PASS, FAIL or OBS, plus the take or evidence path and the time range used in the video. A failing case is reported as failing or left out on purpose — never silently dropped so the final video looks complete. If the user is watching a case-by-case walkthrough, run one case, state its result, and wait for them to continue before starting the next. + +### 5. Assemble episode.json + +Build directory: `build/screen_demo-/`. Full field reference: [references/episode-and-review.md](references/episode-and-review.md). Minimal shape: + +```json +{ + "voice": "zh-CN-YunxiNeural", + "rate": "+5%", + "title": "Feature delivery demo", + "segments": [ + { + "id": "01-entry", + "case": "F-01", + "label": "Entry", + "shot": "take-01.mp4", + "start": 0.5, + "end": 11.5, + "crop": [56, 159, 1512, 738], + "text": "Open the order detail page; the task list and the save action are both here." + } + ] +} +``` + +`shot` is relative to the build directory; `start`/`end` select the range inside that same take; `crop` is `[x, y, w, h]` in source pixels. Speed is hard-checked, not clamped: outside 0.3x–3x both preflight and render fail instead of adjusting the speed, so if narration does not fit, split the segment or cut information — never force it. + +When several acceptance cases share one video, give every segment the `case` id of the run it came from and keep the case order the same as the execution order. The field stays optional; it becomes the on-screen chip and makes it easy to confirm that each case has its own result segment. + +Run the preflight before rendering: + +```bash +python3 /scripts/preflight.py build/screen_demo- +``` + +It checks JSON structure, recordings, intervals, crops, evidence files, cached-TTS contracts and narration red lines. FIX the material on FAIL; do not bring problems to the renderer. + +### 6. Render + +```bash +python3 /scripts/render.py render build/screen_demo- +``` + +This synthesizes narration (edge-tts, cached per segment), times each segment to the narration, draws the label chip and word-synced subtitles, concatenates, loudness-normalizes to -14 LUFS, then runs the built-in quality gate. Output: `screen_demo.mp4`, `timeline.json`, `cover.png`, `preview_*.png`, `quality_gate/report.json`. Nonzero exit or `gate=fail` = fix and re-render. + +### 7. Verify the artifact + +```bash +python3 /scripts/verify_delivery.py build/screen_demo- --require-critic +``` + +Checks decode integrity, spec (h264/1080p30; aac is checked but warn-only), gate report binding (video_sha256), per-shot evidence and the visual critic. Then actually watch it — at least the opening, every key operation, the result read-back and the ending. For an independent review, have a second model/window review key frames without seeing your conclusions first, write `visual-critic.json` (schema in the references file), and only then claim independent review. + +### 8. Attachment gate before posting + +Any mp4 going into a ticket, group chat or review link passes the gate first — it needs no episode.json: + +```bash +python3 /scripts/render.py gate --video --work /quality_gate-attachment +``` + +| Gate code | What viewers feel | Correct fix | +| --- | --- | --- | +| `E101` no audio | silent movie, no idea what's on | render via `render.py` with narration | +| `M101`/`M102` wrong res/fps | blurry text, jitter | re-record/re-render; never scale up to fit | +| `M111` low bitrate notice | possible text softness on dense pages | inspect at 1:1 first; sparse static UIs can compress legitimately. Re-record or re-encode only if text actually smears | +| `T301` long freeze | "is the player stuck?" | cut waiting ranges or add narration/interaction | +| `T304` frozen-heavy | sluggish, "laggy" overall | speed up ranges, drop idle segments, give results their own segment | + +`E102` (non-AAC audio) is the one gate code that is a **warning**: the gate still passes and `verify_delivery.py` only prints a WARN. + +One artifact has exactly one gated file: record the SHA256 before upload, read it back after posting. Never re-encode a gated file by hand — that breaks the evidence chain. + +### 9. Write the delivery note + +In the target project (not inside this skill) write `delivery.md`: environment & versions, real evidence (tickets, request IDs, read-backs), artifact absolute path + duration + resolution + SHA256, the recipe (episode.json, takes, quality report, critic file), the actual commands, what the video proves and what it does not, and status "awaiting user review — not uploaded/published". + +Reply with the conclusion first, then the artifact path, duration, gate result, SHA256 and the unproven items. Never just "the video is done". + +## Feedback mode (defect evidence) + +The same renderer and gate also produce bug/feedback videos. Set `intent: "feedback"` and add an `issue` block; delivery episodes stay unchanged when the fields are absent. + +```json +{ + "intent": "feedback", + "voice": "zh-CN-YunxiNeural", + "issue": { + "id": "BUG-123", + "title": "保存金额后列表仍显示旧总额", + "severity": "major", + "environment": "staging / build ", + "expected": "保存后列表立即显示新的总额", + "actual": "返回列表仍显示旧总额,必须整页刷新才更新", + "repro": ["打开订单详情", "把金额改为 1200", "保存并返回列表"] + }, + "segments": [ + {"id": "01-repro", "phase": "repro", "label": "复现", "shot": "take-01.mp4", + "start": 0.5, "end": 12.0, "text": "照着三个步骤操作,保存后回到列表。"}, + {"id": "02-actual", "phase": "actual", "label": "现象", "shot": "take-01.mp4", + "start": 12.0, "end": 20.0, "text": "列表这里仍然是旧总额,和刚才保存的数值对不上。"} + ] +} +``` + +Rules: + +- `issue.title`, `issue.severity`, `issue.expected` and `issue.actual` are required; `severity` is one of `blocker`, `critical`, `major`, `minor`; `id`, `environment` and `repro` are optional. +- At least one segment must carry `phase: "actual"`. Allowed phases: `context`, `repro`, `actual`, `expected`, `impact`; a `phase` on a delivery episode is rejected. +- The gate report binds a normalized hash of `feedback.json` and the exact bytes of `feedback.generated.md`; `verify_delivery.py` fails a report or markdown edited, missing or misbound after rendering. `feedback.generated.md` is overwritten on every render — add impact and business context in a separate note or an edited copy, never in the generated file. + +Chips, the repro overlay, the generated-file contract and the "report the current build only" rule: [references/episode-and-review.md](references/episode-and-review.md). + +## Common shapes + +- Web/desktop UI: window-level recording cropped to the content area; controls, feedback and read-back all in frame. +- CLI/API: dedicated terminal recording real commands and real responses; mask tokens, internal hosts and debug logs. +- Multi-case acceptance (recommended for 2+ cases): one real run per case, one result segment per case; the `case` field becomes the chip, and narration covers only that case's delta and result. +- Defect report: one continuous take that reproduces the problem, with `phase` segments for repro and actual; the `expected` state can be one narration line or a separate real segment when an earlier build is legitimately available. +- No visual UI: show real API responses, logs or data — never fabricate a fake UI to stand in for the product. +- Engineering acceptance: terminal and evidence frames are fine, but lead with the business conclusion. + +## Boundaries + +- No static screenshots, animations or mock pages standing in for a real feature; if it doesn't run, fail loud. +- Never touch production data, send messages or fire notifications to "create a shot"; confirm test targets and recoverability before any real side effect. +- No public upload or publishing until the user explicitly says "send" — until then, local review only. +- Never shrink resolution/bitrate/fps to cut file size: deliverables are 1920x1080/30fps; control size with duration; anything re-encoded must re-run the gate, and the posted file must match the gated SHA256. +- Never end on a waiting state ("creating…/loading…"); end on the result itself. +- Never loosen the gate profile, delete gate evidence or skip the critic to manufacture a PASS. +- Company source, customer data, credentials and unreleased business information must not enter public repos or public media directories. diff --git a/external_plugins/delivery-video/skills/delivery-video/references/episode-and-review.md b/external_plugins/delivery-video/skills/delivery-video/references/episode-and-review.md new file mode 100644 index 00000000..670f6212 --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/references/episode-and-review.md @@ -0,0 +1,197 @@ +# Episode, takes and review details + +## episode.json fields + +`render.py render ` reads `/episode.json`. Build directories are named `screen_demo` or `screen_demo-`. + +| Field | Required | Notes | +| --- | --- | --- | +| `voice` | yes | edge-tts voice, e.g. `zh-CN-YunxiNeural`, `en-US-AndrewNeural` | +| `rate` | no | speech rate, default `+5%`, format `+N%` or `-N%` | +| `title` | no | internal identification only | +| `intent` | no | `delivery` (default) or `feedback`; feedback requires the `issue` block | +| `issue` | for feedback | defect report metadata, see below | +| `segments` | yes | non-empty array, unique `id`s | +| `segments[].id` | yes | ASCII id, e.g. `01-entry` | +| `segments[].case` | no | acceptance-case chip shown top-left, e.g. `D-02`; recommended when the video covers 2+ cases | +| `segments[].phase` | for feedback | `context`, `repro`, `actual`, `expected` or `impact`; becomes the second chip | +| `segments[].label` | yes | short top-left label, 2–10 chars | +| `segments[].shot` | yes | recording file relative to the build directory | +| `segments[].start` | yes | range start in the source, seconds | +| `segments[].end` | yes | range end, must exceed start | +| `segments[].source` | no | `browser` requires `.evidence.json`; `demo-stage` requires headless-stage evidence; window recordings may say `window` or omit | +| `segments[].crop` | no | `[x, y, w, h]` in source pixels. Default `[260, 64, 1340, 780]` for window/browser takes; preflight treats `source: "demo-stage"` takes as the full frame `[0, 0, 1920, 1080]` (the headless stage is captured full-frame) | +| `segments[].text` | yes | one continuous narration line, no newlines; subtitles auto-break at punctuation | + +### Feedback issues + +`intent: "feedback"` turns the artifact into a defect report while keeping the same recording and gate rules. + +| Field | Required | Notes | +| --- | --- | --- | +| `issue.title` | yes | one sentence naming the wrong behaviour | +| `issue.severity` | yes | `blocker`, `critical`, `major` or `minor` | +| `issue.expected` | yes | what a correct build must show | +| `issue.actual` | yes | what this build actually shows | +| `issue.id` | no | ticket or case id | +| `issue.environment` | no | build, environment, account or data | +| `issue.repro` | no | up to 12 steps, one string per step | + +At least one segment must use `phase: "actual"`. `render.py` writes `feedback.json` with the +issue, the segment phases and the artifact SHA256; `verify_delivery.py` treats a missing or +misbound report as a failure, and the gate report binds a normalized hash of the report fields +plus the exact `feedback.generated.md` bytes, so editing either file after rendering fails the +gate. Delivery episodes without `intent` keep the original contract. + +During `phase: "repro"` segments the renderer also draws the first five `issue.repro` steps in +the top-right corner; the full list stays in the generated markdown. The severity becomes the +first on-screen chip and the phase the second, so a viewer can tell repro from symptom without +narration. A feedback video reports what the current build does: never mix in footage from an +older build, claim a fix, or hide a passing case to make the defect look worse. A `phase` on a +delivery episode is rejected, so the two modes cannot be mixed by accident. + +### Generated feedback files + +| File | Contents | +| --- | --- | +| `feedback.json` | machine-readable issue, segment phases and artifact SHA256; the gate report binds its normalized content hash | +| `feedback.generated.md` | ticket-ready draft: expected/actual, numbered repro steps, per-segment in-video timing and source range, artifact hash and gate result; the gate report binds its exact byte hash | + +`feedback.generated.md` is rewritten on every render — edit a copy or keep additions in a +separate note. The draft deliberately does not claim a fix, a root cause or coverage of other +environments. + +### Case list (recommended, not enforced) + +Only for a video covering two or more cases: write one row per case — case id, what it proves, entry, precondition/data, expected observable result — and reuse the same ids in take names, `episode.json` and the delivery note. Keep it in the target project beside the build directory, never in the plugin; template: `examples/cases.example.md`. + +### Choosing time ranges + +- Leave 0.3–0.8s before the action starts so the entry reads. +- Success feedback, read-back and key states must fall inside the range. +- The last segment must be a result, not a waiting state. +- A stretch of static frames longer than ~8s inside a take means cut it: idle time makes the whole video feel stuck (the gate's `T301`/`T304` enforce this). +- You may cut waiting gaps, but never merge pieces of different runs into one success. +- Playback speed lands between 0.3x and 3x by construction; below 0.3x the action is too slow or the narration too short, above 3x the narration overruns the range — split or trim instead of forcing. +- Aim for 15–35 characters per narration segment; important results get their own segment. +- In a multi-case video, keep one result segment per case and follow the execution order, so the viewer can tell where one case ends and the next begins. + +### Browser recording evidence + +Browser segments carry a `.evidence.json` beside the take; the full contract (allowed `kind`s, required fields, 1920x1080 size, 0.2s duration tolerance) is in [recording.md](recording.md). + +### Demo-stage evidence + +Segments with `source: "demo-stage"` come from a headless scripted-stage tool. The evidence must show `kind: "demo-stage-recording"`, `status: "passed"`, all self-checks passed, `capture.headless: true`, `capture.window_visible: false`, and a `video` block with matching sha256/fps/frames/duration. A visible window during recording fails — the stage must not steal focus. + +### TTS cache contract + +Narration is cached at `tts/.mp3` + `tts/.json`. The clock JSON records `{text, voice, rate, words[]}`; changing any of them without deleting the cache files fails the render with a mismatch. Editing narration = delete that segment's two cache files and re-render. + +## visual-critic.json + +An independent visual review, ideally by a second model or reviewer who has not seen the maker's conclusions. Schema: + +```json +{ + "status": "pass", + "critical": [], + "dimensions": { + "subtitle_legibility": 0.96, + "subtitle_safe_margins": 0.98, + "interface_legibility": 0.9, + "composition": 0.92, + "business_content_alignment": 0.97 + }, + "reviewed_frames": [ + {"time_s": 1.0, "scene": "entry"}, + {"time_s": 8.0, "scene": "key action"}, + {"time_s": 16.0, "scene": "result read-back"} + ], + "observations": [ + "Subtitles complete; no controls covered.", + "Success message matches the narration." + ], + "limitations": [ + "Static frames only; does not prove full click ordering or database consistency." + ], + "reviewer": "independent-review", + "video_sha256": "" +} +``` + +`video_sha256` must match the current artifact, otherwise the review binds to nothing. + +## Reading the quality report + +`quality_gate/report.json` (`profile: delivery-v1`) — the important parts: + +- `status` is `pass` and `hard_failures` is empty. +- `score.total` plus the per-check details; don't judge by the total alone. +- `warnings` — actionable anomalies such as `E301` (long silence); keep them visible. +- `notices` — advisory measurements (e.g. bitrate density). They do not reduce the score; the gate-code table in [SKILL.md](../SKILL.md) says what to do with each code. +- When `verify_delivery.py --require-critic` is requested for an external delivery and `visual-critic.json` is missing, verification fails with `missing independent visual review: /visual-critic.json`. + +The gate binds to the artifact via `video_sha256`; after any re-render the gate re-runs automatically with `render.py render`. For an attachment gate on an arbitrary mp4, run `render.py gate --video --work ` — it writes its own report. + +## delivery.md template + +````markdown +# delivery demo + +## Running version + +- Environment: +- Frontend/backend/local version: +- Verification entry: + +## Real evidence + +- +- The video covers ; is covered by . + +## Case results (optional, recommended for multi-case videos) + +| Case | Result | Evidence | Video range | +| --- | --- | --- | --- | +| | PASS / FAIL / OBS | | | + +## Artifact + +- File: +- Duration/resolution/fps/codec: <...> +- SHA256: <...> +- Recipe: +- Takes: +- Gate: , PASS, +- Attachment gate: , PASS, profile delivery-v1, uploaded SHA256 <...> +- Visual review: + +## Commands actually run + +```bash + +``` + +## Boundaries + +- Proven: <...> +- Not proven: <...> + +## Status + +Awaiting user review — not uploaded / not published. +```` + +## Common failures + +| Symptom | Fix | +| --- | --- | +| `missing real recording` | `shot` path wrong or file not in the build directory | +| `invalid recording interval` | `start/end` beyond the source, or the cut lands mid-action | +| `recording speed outside 0.3x..3x` | shorten narration, widen the range or split the segment | +| Large black areas in a take | window off-screen or occluded during recording; re-record | +| Subtitles clipped or covering controls | adjust `crop` or shorten the narration | +| Gate hard failure | open `report.json`, fix the material per the highest-severity item; never touch thresholds | +| Final duration mismatch | re-render from scratch; do not trim the artifact by hand | +| Portrait need | this pipeline is 16:9 landscape by design; use a portrait-dedicated recipe instead of hard-cropping | diff --git a/external_plugins/delivery-video/skills/delivery-video/references/recording.md b/external_plugins/delivery-video/skills/delivery-video/references/recording.md new file mode 100644 index 00000000..81f69d6d --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/references/recording.md @@ -0,0 +1,52 @@ +# Recording real screens + +The skill renders whatever recordings you point `episode.json` at. This page covers how to get clean source takes on common platforms. Whatever tool you use, the rules in the SKILL.md workflow (one real run, no retakes posing as different runs, dedicated test data) apply. + +## What a good take looks like + +- 1920x1080 or larger, landscape; the renderer crops to a 1080p canvas. +- Steady 30fps or higher; avoid variable-frame-rate phone-mirror captures when possible. +- The whole interaction in one continuous take: entry → action → result read-back. +- No notifications, no unrelated windows, no personal data in frame. + +## macOS + +**System screen recording** (no install): `Cmd+Shift+5` → Record Selected Portion, or QuickTime Player → New Screen Recording. Records to `.mov`; transcode before use: + +```bash +ffmpeg -i take-01.mov -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a aac take-01.mp4 +``` + +Grant Screen Recording permission to the capturing app in System Settings → Privacy & Security. + +## Windows + +Xbox Game Bar (`Win+G` → Capture) records the active window as MP4. For window-precise, longer takes OBS Studio (free) with a Window capture source at 1920x1080@30 output is the reliable default. + +## Linux + +OBS Studio with a Window capture (XComposite) or Screen capture (PipeWire) source. On Wayland use the desktop portal capture; set output to 1920x1080, 30fps, x264 CRF 18–20. + +## Browser content: scripted screencast (optional, strongest evidence) + +For web apps, a scripted CDP screencast gives a headless, reproducible capture with an evidence file. Any tool that drives Chrome via the DevTools Protocol and calls `Page.startScreencast` works; examples: Playwright with `page.video` context recording, or a small script using chrome-remote-interface. Write `.evidence.json` next to the mp4 so preflight can bind it: + +```json +{ + "kind": "cdp-screencast", + "port": 9222, + "video": "take-01.mp4", + "size": [1920, 1080], + "frames": 312, + "video_s": 10.4, + "sha256": "" +} +``` + +`kind` may also be `"browser-demo"` for script-driven demos. Mark the segment `"source": "browser"` in episode.json — a browser segment without a matching evidence file fails preflight. `sha256` must match the mp4, `frames` must be at least 1, `port` (when present) must be a positive integer, `size` must be exactly 1920x1080 for browser takes, and the recorded duration must match the file within 0.2s. + +Headless screencasts contain no mouse cursor — if the audience needs to see where clicks land, inject a highlight from your driver script or annotate click positions in post. + +## Cropping to the content area + +Take one screenshot of the same window and measure the content rectangle, then set `crop` in episode.json to `[x, y, w, h]` in source pixels. The crop must keep both the operating controls and the result feedback in frame; `x+w` and `y+h` must stay inside the source. Crop beats blur/mosaic for hiding sidebars: mosaic leaks edges and covers controls. diff --git a/external_plugins/delivery-video/skills/delivery-video/scripts/preflight.py b/external_plugins/delivery-video/skills/delivery-video/scripts/preflight.py new file mode 100644 index 00000000..8d726ba4 --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/scripts/preflight.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +"""Fail-loud preflight for a delivery-video screen_demo build directory. + +Usage: preflight.py + +Checks episode.json structure, real recordings, intervals, crops, browser +recording evidence, TTS cache contracts and narration red lines before the +render step. FAIL means fix the material, not the thresholds. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import math +import re +import shutil +import subprocess +import sys +from pathlib import Path + +BUILD_NAME = re.compile(r"^screen_demo(?:-[A-Za-z0-9_-]+)?$") +SEGMENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") +RATE = re.compile(r"^[+-]\d+%$") +EVIDENCE_KINDS = {"cdp-screencast", "browser-demo"} +DEMO_STAGE_KIND = "demo-stage-recording" +DEMO_STAGE_CROP = [0, 0, 1920, 1080] +ALLOWED_SOURCES = {"browser", "window", "demo-stage"} + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from render import ( # noqa: E402 + AI_CLICHE_PATTERNS, + DEFAULT_CROP, + FPS, + INTERNAL_TERMS, + LEAD as LEAD_S, + RenderError, + SPEED_MAX, + SPEED_MIN, + TAIL as TAIL_S, + check_edge_tts_version, + feedback_errors, + font_path, +) + + +class CheckError(Exception): + pass + + +def run_json(args: list[str]) -> dict: + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + raise CheckError(f"could not run {args[0]}: {exc}") from exc + if result.returncode: + raise CheckError(f"ffprobe failed for {args[-1]}: {result.stderr.strip()[-500:]}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise CheckError(f"ffprobe returned invalid JSON for {args[-1]}: {exc}") from exc + + +def probe_video(path: Path) -> tuple[int, int, float]: + data = run_json([ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height:format=duration", + "-of", "json", str(path), + ]) + streams = data.get("streams") or [] + if not streams: + raise CheckError(f"no video stream: {path}") + duration = data.get("format", {}).get("duration") + if duration in (None, "N/A"): + raise CheckError(f"no duration: {path}") + return int(streams[0]["width"]), int(streams[0]["height"]), float(duration) + + +def probe_duration(path: Path) -> float: + data = run_json([ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "json", str(path), + ]) + duration = data.get("format", {}).get("duration") + if duration in (None, "N/A"): + raise CheckError(f"no duration: {path}") + return float(duration) + + +def load_episode(path: Path) -> dict: + if not path.is_file(): + raise CheckError(f"missing {path}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CheckError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(data, dict): + raise CheckError("episode.json must be an object") + return data + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def check_evidence(prefix: str, source: Path, declared_browser: bool, + errors: list[str]) -> dict | None: + """Browser source recordings need a matching .evidence.json.""" + before = len(errors) + evidence_path = source.with_suffix(".evidence.json") + if not evidence_path.is_file(): + if declared_browser: + errors.append( + f"{prefix}: browser shot needs {evidence_path.name}; " + "record with a scripted browser screencast tool that writes evidence" + ) + return None + try: + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{prefix}: invalid evidence JSON {evidence_path.name}: {exc}") + return None + if not isinstance(evidence, dict): + errors.append(f"{prefix}: evidence must be an object: {evidence_path.name}") + return None + kind = evidence.get("kind") + if kind not in EVIDENCE_KINDS: + errors.append(f"{prefix}: evidence kind {kind!r} is not a browser recording") + port = evidence.get("port") + if port is not None and (not isinstance(port, int) or isinstance(port, bool) or port <= 0): + errors.append(f"{prefix}: evidence port {port!r} must be a positive integer") + if evidence.get("sha256") != sha256(source): + errors.append(f"{prefix}: evidence sha256 does not match {source.name}") + try: + actual = probe_duration(source) + recorded = float(evidence.get("video_s")) + except (CheckError, TypeError, ValueError) as exc: + errors.append(f"{prefix}: evidence duration unreadable: {exc}") + return evidence + if abs(actual - recorded) > 0.2: + errors.append( + f"{prefix}: evidence duration {recorded:.2f}s != source {actual:.2f}s" + ) + if int(evidence.get("frames") or 0) < 1: + errors.append(f"{prefix}: evidence has no captured frames") + if len(errors) == before: + print( + f"OK {prefix}: evidence kind={kind} port={evidence.get('port')} " + f"frames={evidence.get('frames')} sha={str(evidence.get('sha256'))[:12]}" + ) + return evidence + + +def check_demo_stage_evidence(prefix: str, source: Path, errors: list[str]) -> dict | None: + """Headless stage recording: kind=demo-stage-recording, self-checks all passed.""" + before = len(errors) + evidence_path = source.with_suffix(".evidence.json") + if not evidence_path.is_file(): + errors.append( + f"{prefix}: demo-stage shot needs {evidence_path.name}; " + "record with a headless scripted-stage tool that writes evidence" + ) + return None + try: + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{prefix}: invalid evidence JSON {evidence_path.name}: {exc}") + return None + if not isinstance(evidence, dict): + errors.append(f"{prefix}: evidence must be an object: {evidence_path.name}") + return None + if evidence.get("kind") != DEMO_STAGE_KIND: + errors.append( + f"{prefix}: evidence kind {evidence.get('kind')!r} is not a demo-stage recording" + ) + if evidence.get("status") != "passed": + errors.append(f"{prefix}: demo-stage evidence status {evidence.get('status')!r} != 'passed'") + + checks = evidence.get("checks") + if not isinstance(checks, list) or not checks: + errors.append(f"{prefix}: demo-stage evidence has no self-checks") + else: + failed = [ + str(item.get("id")) for item in checks + if not isinstance(item, dict) or item.get("passed") is not True + ] + if failed: + errors.append(f"{prefix}: demo-stage self-checks failed: {', '.join(failed)}") + + capture = evidence.get("capture") + if not isinstance(capture, dict): + errors.append(f"{prefix}: demo-stage evidence lacks capture block") + capture = {} + if capture.get("headless") is not True: + errors.append(f"{prefix}: demo-stage recording must be headless (capture.headless=true)") + if capture.get("window_visible") is not False: + errors.append(f"{prefix}: demo-stage window was visible; recording must not steal focus") + + video = evidence.get("video") + if not isinstance(video, dict): + errors.append(f"{prefix}: demo-stage evidence lacks video block") + return None + if video.get("sha256") != sha256(source): + errors.append(f"{prefix}: evidence sha256 does not match {source.name}") + if video.get("fps") != FPS: + errors.append(f"{prefix}: demo-stage evidence fps {video.get('fps')!r} != {FPS}") + frames = video.get("frames") + if not isinstance(frames, int) or isinstance(frames, bool) or frames < 1: + errors.append(f"{prefix}: demo-stage evidence has no captured frames") + try: + actual = probe_duration(source) + recorded = float(video.get("duration_s")) + except (CheckError, TypeError, ValueError) as exc: + errors.append(f"{prefix}: demo-stage duration unreadable: {exc}") + return None + if abs(actual - recorded) > 0.2: + errors.append( + f"{prefix}: demo-stage duration {recorded:.2f}s != source {actual:.2f}s" + ) + if len(errors) == before: + print( + f"OK {prefix}: demo-stage scene={evidence.get('scene', {}).get('id')} " + f"frames={frames} sha={str(video.get('sha256'))[:12]}" + ) + return { + "kind": DEMO_STAGE_KIND, + "size": video.get("size"), + "frames": frames, + "sha256": video.get("sha256"), + } + + +def check_evidence_size(prefix: str, evidence: dict | None, width: int, height: int, + errors: list[str]) -> None: + if evidence is None: + return + if [width, height] != [1920, 1080]: + errors.append(f"{prefix}: browser source must be 1920x1080, got {width}x{height}") + if evidence.get("size") != [width, height]: + errors.append( + f"{prefix}: evidence size {evidence.get('size')!r} != source {width}x{height}" + ) + + +def check_crop(segment_id: str, crop: object, width: int, height: int, + errors: list[str], default: list[int] | None = None) -> None: + if crop is None: + crop = default or DEFAULT_CROP + if (not isinstance(crop, list) or len(crop) != 4 + or not all(isinstance(v, int) and not isinstance(v, bool) for v in crop)): + errors.append(f"{segment_id}: crop must be [x, y, w, h] integers") + return + x, y, w, h = crop + if x < 0 or y < 0 or w <= 0 or h <= 0: + errors.append(f"{segment_id}: crop has non-positive size or negative origin {crop}") + elif x + w > width or y + h > height: + errors.append( + f"{segment_id}: crop {crop} exceeds source {width}x{height}" + ) + + +def check_tts(build: Path, segment: dict, start: float, end: float, + voice: str, rate: str, errors: list[str]) -> bool: + segment_id = segment["id"] + mp3 = build / "tts" / f"{segment_id}.mp3" + clock = build / "tts" / f"{segment_id}.json" + if not mp3.exists() and not clock.exists(): + return True + if not mp3.is_file() or not clock.is_file(): + errors.append(f"{segment_id}: incomplete TTS cache, need both .mp3 and .json") + return False + try: + cached = json.loads(clock.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{segment_id}: invalid TTS clock JSON: {exc}") + return False + if not isinstance(cached, dict): + errors.append(f"{segment_id}: TTS clock must be a JSON object") + return False + expected = {"text": segment["text"], "voice": voice, "rate": rate} + for key, value in expected.items(): + if cached.get(key) != value: + errors.append( + f"{segment_id}: TTS cache mismatch for {key}; " + "delete the stale tts/.mp3 and .json, then rerender" + ) + try: + audio_duration = probe_duration(mp3) + except CheckError as exc: + errors.append(str(exc)) + return False + seconds = math.ceil((audio_duration + LEAD_S + TAIL_S) * FPS) / FPS + speed = (end - start) / seconds if seconds else 0.0 + if not SPEED_MIN <= speed <= SPEED_MAX: + errors.append( + f"{segment_id}: recording speed {speed:.2f}x outside " + f"{SPEED_MIN:g}x..{SPEED_MAX:g}x " + f"(interval {end - start:.2f}s, narration {seconds:.2f}s)" + ) + return False + + +def check_redline(texts: list[str], errors: list[str], warnings: list[str]) -> None: + for index, text in enumerate(texts, 1): + for name, pattern in AI_CLICHE_PATTERNS: + if pattern.search(text): + errors.append( + f"narration {index}: AI-cliche pattern {name}; " + "write a plain factual statement from the viewer's perspective" + ) + lowered = text.lower() + for term in INTERNAL_TERMS: + if term in lowered: + warnings.append( + f"narration {index}: internal jargon '{term}' is fine for technical " + "acceptance; remove it for external deliveries" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build", type=Path, help="screen_demo[-] build directory") + args = parser.parse_args() + + build_path = args.build.expanduser() + build = build_path.resolve() + episode_path = build / "episode.json" + errors: list[str] = [] + warnings: list[str] = [] + + missing_tools: list[str] = [] + for executable in ("ffmpeg", "ffprobe"): + if shutil.which(executable) is None: + missing_tools.append(executable) + errors.append(f"{executable} is not on PATH") + try: + check_edge_tts_version() + except RenderError as exc: + errors.append(str(exc)) + try: + importlib.metadata.version("Pillow") + except importlib.metadata.PackageNotFoundError: + errors.append('Pillow is not installed; run: pip install Pillow') + try: + font_path() + except RenderError as exc: + errors.append(str(exc)) + + if not build.is_dir(): + errors.append(f"missing build directory: {build}") + return report(errors, warnings) + if missing_tools: + return report(errors, warnings) + if not BUILD_NAME.match(build_path.name): + errors.append( + f"build directory {build_path.name!r} is not accepted by render.py; " + "use screen_demo or screen_demo-" + ) + try: + episode = load_episode(episode_path) + except CheckError as exc: + errors.append(str(exc)) + return report(errors, warnings) + + voice_value = episode.get("voice") + rate_value = episode.get("rate", "+5%") + voice_ok = isinstance(voice_value, str) and bool(voice_value.strip()) + rate_ok = isinstance(rate_value, str) and bool(RATE.match(rate_value)) + if not voice_ok: + errors.append("voice must be a non-empty string") + if not rate_ok: + errors.append(f"rate must look like +5% or -10%, got {rate_value!r}") + + segments = episode.get("segments") + if not isinstance(segments, list) or not segments: + errors.append("segments must be a non-empty array") + return report(errors, warnings) + + seen_ids: set[str] = set() + texts: list[str] = [] + tts_missing: list[str] = [] + tts_voice = voice_value if voice_ok else "" + tts_rate = rate_value if rate_ok else "+5%" + for index, segment in enumerate(segments): + prefix = f"segments[{index}]" + if not isinstance(segment, dict): + errors.append(f"{prefix}: must be an object") + continue + segment_id = segment.get("id") + if not isinstance(segment_id, str) or not SEGMENT_ID.match(segment_id): + errors.append(f"{prefix}: id must be ASCII letters/digits/_/- and start alphanumeric") + continue + if segment_id in seen_ids: + errors.append(f"{segment_id}: duplicate id") + continue + seen_ids.add(segment_id) + prefix = segment_id + + label = segment.get("label") + if not isinstance(label, str) or not label.strip(): + errors.append(f"{prefix}: label must be a non-empty string") + text = segment.get("text") + text_ok = isinstance(text, str) and bool(text.strip()) and "\n" not in text and "\r" not in text + if not isinstance(text, str) or not text.strip(): + errors.append(f"{prefix}: text must be a non-empty string") + elif "\n" in text or "\r" in text: + errors.append(f"{prefix}: text must be one paragraph without newlines") + else: + texts.append(text) + + shot = segment.get("shot") + if not isinstance(shot, str) or not shot.strip(): + errors.append(f"{prefix}: shot must be a relative path") + continue + shot_path = Path(shot) + if shot_path.is_absolute() or ".." in shot_path.parts: + errors.append(f"{prefix}: shot must stay inside the build directory") + continue + source_kind = segment.get("source") + if source_kind is not None and source_kind not in ALLOWED_SOURCES: + errors.append( + f"{prefix}: source must be one of {sorted(ALLOWED_SOURCES)}, got {source_kind!r}" + ) + continue + source = (build / shot_path).resolve() + try: + source.relative_to(build) + except ValueError: + errors.append(f"{prefix}: shot resolves outside the build directory") + continue + if not source.is_file() or source.stat().st_size == 0: + errors.append(f"{prefix}: missing or empty recording {source}") + continue + if source_kind == "demo-stage": + evidence = check_demo_stage_evidence(prefix, source, errors) + else: + evidence = check_evidence(prefix, source, source_kind == "browser", errors) + + start = segment.get("start") + end = segment.get("end") + if (not isinstance(start, (int, float)) or isinstance(start, bool) + or not isinstance(end, (int, float)) or isinstance(end, bool)): + errors.append(f"{prefix}: start and end must be numbers") + continue + if start < 0 or end <= start: + errors.append(f"{prefix}: require 0 <= start < end, got {start}..{end}") + continue + + try: + width, height, duration = probe_video(source) + except CheckError as exc: + errors.append(str(exc)) + continue + check_evidence_size(prefix, evidence, width, height, errors) + if end > duration + 0.1: + errors.append( + f"{prefix}: interval ends at {end:.2f}s beyond source duration {duration:.2f}s" + ) + continue + check_crop( + prefix, segment.get("crop"), width, height, errors, + default=DEMO_STAGE_CROP if source_kind == "demo-stage" else None, + ) + print( + f"OK {prefix}: {shot} {start:.2f}-{end:.2f}s " + f"source={width}x{height} duration={duration:.2f}s" + ) + if voice_ok and rate_ok and text_ok: + if check_tts(build, segment, float(start), float(end), + tts_voice, tts_rate, errors): + tts_missing.append(prefix) + + errors.extend(feedback_errors(episode, segments)) + if texts: + check_redline(texts, errors, warnings) + if tts_missing: + warnings.append( + f"{len(tts_missing)} segment(s) have no cached TTS; speed range not checked: " + + ", ".join(tts_missing) + ) + return report(errors, warnings) + + +def report(errors: list[str], warnings: list[str]) -> int: + for warning in warnings: + print(f"WARN: {warning}") + if errors: + for error in errors: + print(f"FAIL: {error}") + print(f"PREFLIGHT=FAIL issues={len(errors)}") + return 1 + print(f"PREFLIGHT=PASS warnings={len(warnings)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/delivery-video/skills/delivery-video/scripts/render.py b/external_plugins/delivery-video/skills/delivery-video/scripts/render.py new file mode 100644 index 00000000..b6833295 --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/scripts/render.py @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +"""Self-contained delivery-demo renderer: episode.json -> screen_demo.mp4. + +Subcommands: + render render all segments + run the built-in gate + gate --video --work run the delivery gate on any mp4 (attachment gate) + +Requires: python3.9+, ffmpeg & ffprobe on PATH, +`pip install "edge-tts>=7.3.1" Pillow`. + +Fail-loud by design: bad episode.json, missing recordings, out-of-range speed, +narration redline hits and gate hard failures all abort with a nonzero exit. +""" + +from __future__ import annotations + +import argparse +import asyncio +import bisect +import importlib.metadata +import json +import math +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +W, H, FPS = 1920, 1080, 30 +LEAD = 0.15 +TAIL = 0.45 +PROFILE = "delivery-v1" +SPEED_MIN, SPEED_MAX = 0.3, 3.0 +DEFAULT_CROP = [260, 64, 1340, 780] +MAX_LINE = 24 +MIN_EDGE_TTS = (7, 3, 1) +INTENTS = {"delivery", "feedback"} +SEVERITIES = { + "blocker": ("P0 BLOCKER", (220, 38, 38)), + "critical": ("P1 CRITICAL", (234, 88, 12)), + "major": ("P2 MAJOR", (217, 119, 6)), + "minor": ("P3 MINOR", (71, 85, 105)), +} +PHASE_LABELS = { + "context": "CONTEXT", + "repro": "REPRO", + "actual": "ACTUAL", + "expected": "EXPECTED", + "impact": "IMPACT", +} +MAX_REPRO_STEPS = 12 +REPRO_PANEL_LIMIT = 5 + +BREAK_AFTER = set("。!?;!?;") +SOFT_BREAK = set(",、,") + +FONT_CANDIDATES = [ + # macOS + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Medium.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + # Linux (Noto CJK / WQY) + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + str(Path.home() / "Library/Fonts/PingFang.ttc"), + str(Path.home() / ".local/share/fonts/NotoSansCJK-Regular.ttc"), + str(Path.home() / ".fonts/NotoSansCJK-Regular.ttc"), + # Windows + "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/simhei.ttf", +] +if os.environ.get("WINDIR"): + windows_fonts = Path(os.environ["WINDIR"]) / "Fonts" + FONT_CANDIDATES[:0] = [ + str(windows_fonts / "msyh.ttc"), + str(windows_fonts / "simhei.ttf"), + ] + +# Narration red lines for external deliveries. AI-cliche patterns abort the +# render; internal-jargon terms only warn because technical acceptance videos +# may legitimately quote real commands and evidence. +INTERNAL_TERMS = ( + "工作流", "管线", "pipeline", "接口", "数据库", "后端", "前端", + "部署", "构建", "docker", "commit", "refactor", +) +AI_CLICHE_PATTERNS = ( + ("not-x-but-x cliche", + re.compile(r"这[^。!?;\n,,]{0,4}不是[^。!?;\n]{1,40}[,,]?\s*(?:而是|是)")), +) + + +class RenderError(Exception): + pass + + +def feedback_errors(episode: dict, segments: list) -> list[str]: + """Validate the optional feedback shape; delivery episodes stay unchanged.""" + errors: list[str] = [] + intent = episode.get("intent", "delivery") + if intent not in INTENTS: + errors.append(f"intent must be one of {sorted(INTENTS)}, got {intent!r}") + return errors + issue = episode.get("issue") + if intent == "delivery": + if issue is not None and not isinstance(issue, dict): + errors.append("issue must be an object when present") + for segment in segments: + if isinstance(segment, dict) and segment.get("phase") is not None: + errors.append( + f"{segment.get('id')}: phase is only valid when intent is 'feedback'" + ) + return errors + if not isinstance(issue, dict): + errors.append("intent 'feedback' requires an issue object") + return errors + for key in ("title", "severity", "expected", "actual"): + value = issue.get(key) + if not isinstance(value, str) or not value.strip(): + errors.append(f"issue.{key} must be a non-empty string") + severity = issue.get("severity") + if isinstance(severity, str) and severity and severity not in SEVERITIES: + errors.append(f"issue.severity must be one of {sorted(SEVERITIES)}, got {severity!r}") + repro = issue.get("repro", []) + if not isinstance(repro, list) or not all( + isinstance(item, str) and item.strip() for item in repro + ): + errors.append("issue.repro must be an array of non-empty strings") + elif len(repro) > MAX_REPRO_STEPS: + errors.append(f"issue.repro supports at most {MAX_REPRO_STEPS} steps") + phases: list[str] = [] + for segment in segments: + if not isinstance(segment, dict): + continue + phase = segment.get("phase") + if phase is None: + continue + if phase not in PHASE_LABELS: + errors.append( + f"{segment.get('id')}: phase must be one of {sorted(PHASE_LABELS)}, got {phase!r}" + ) + continue + phases.append(phase) + if "actual" not in phases: + errors.append("feedback episode needs at least one segment with phase 'actual'") + return errors + + +def run(args: list[str]) -> str: + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + raise RenderError(f"could not run {args[0]}: {exc}") from exc + if result.returncode: + raise RenderError(f"{args[0]} failed:\n{result.stderr[-2000:]}") + return result.stdout + + +def duration(path: Path) -> float: + return float(run(["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(path)]).strip()) + + +def probe_json(path: Path) -> dict: + return json.loads(run(["ffprobe", "-v", "error", "-print_format", "json", + "-show_streams", "-show_format", str(path)])) + + +def sha256(path: Path) -> str: + import hashlib + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def feedback_content_sha256(feedback: dict) -> str: + """Hash the normalized report fields, independent of JSON formatting.""" + import hashlib + canonical = json.dumps( + feedback, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def check_tools() -> None: + missing = [name for name in ("ffmpeg", "ffprobe") if shutil.which(name) is None] + if missing: + raise RenderError( + "missing required executable(s): " + + ", ".join(missing) + + "; install ffmpeg and make sure both binaries are on PATH" + ) + + +def check_pillow() -> None: + try: + importlib.metadata.version("Pillow") + except importlib.metadata.PackageNotFoundError as exc: + raise RenderError('Pillow is not installed; run: pip install Pillow') from exc + + +def font_path() -> str: + for candidate in FONT_CANDIDATES: + if Path(candidate).is_file(): + return candidate + raise RenderError( + "No CJK font found for labels and subtitles; install Noto Sans CJK " + "(Linux: fonts-noto-cjk; Windows/macOS usually provide Microsoft YaHei " + "or PingFang), then rerun preflight" + ) + + +def find_font(size: int): + try: + from PIL import ImageFont + except ImportError as exc: + raise RenderError('Pillow is not installed; run: pip install Pillow') from exc + path = font_path() + try: + return ImageFont.truetype(path, size) + except OSError as exc: + raise RenderError(f"could not load the CJK font {path}: {exc}") from exc + + +def check_narration(text: str, segment_id: str) -> None: + cliches = [name for name, pattern in AI_CLICHE_PATTERNS if pattern.search(text)] + if cliches: + raise RenderError( + f"narration red line hit on {segment_id}: AI-cliche pattern " + + ", ".join(cliches) + + " -- rewrite as a plain factual statement from the viewer's perspective") + for term in INTERNAL_TERMS: + if term in text.lower() or term in text: + print(f"WARN: narration for {segment_id} mentions internal jargon " + f"'{term}' -- fine for technical acceptance, remove it for " + "external deliveries", flush=True) + + +def check_edge_tts_version() -> None: + try: + raw = importlib.metadata.version("edge-tts") + except importlib.metadata.PackageNotFoundError as exc: + raise RenderError( + 'edge-tts is not installed; run: pip install "edge-tts>=7.3.1" Pillow' + ) from exc + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", raw) + if not match: + raise RenderError(f"cannot parse edge-tts version: {raw!r}") + installed = tuple(int(part) for part in match.groups()) + if installed < MIN_EDGE_TTS: + minimum = ".".join(str(part) for part in MIN_EDGE_TTS) + raise RenderError( + f"edge-tts {raw} is too old and is known to return HTTP 403; " + f'install edge-tts>={minimum} with: pip install "edge-tts>={minimum}"' + ) + + +def align_clock(text: str, words: list[dict]) -> list[dict]: + """Expand TTS word boundaries into character clocks using the original text. + + edge-tts WordBoundary events omit punctuation. Re-threading their timings + through the source narration preserves commas and sentence ends so subtitle + grouping can break where a viewer expects it. + """ + if not words: + return [] + chars: list[dict] = [] + cursor = 0 + previous_end = 0 + for word in words: + token = str(word.get("char") or "") + if not token: + continue + index = text.find(token, cursor) + if index < 0: + raise RenderError( + f"TTS word boundary {token!r} is not present in narration {text!r}" + ) + gap = text[cursor:index] + for char in gap: + if char.isspace(): + continue + chars.append({"char": char, "start_ms": previous_end, "end_ms": previous_end}) + if ( + any(char.isspace() for char in gap) + and token[0].isascii() + and token[0].isalnum() + ): + chars.append( + {"char": " ", "start_ms": previous_end, "end_ms": previous_end} + ) + + start = int(word.get("start_ms") or 0) + end = max(start + 1, int(word.get("end_ms") or 0)) + for offset, char in enumerate(token): + char_start = round(start + (end - start) * offset / len(token)) + char_end = round(start + (end - start) * (offset + 1) / len(token)) + chars.append( + {"char": char, "start_ms": char_start, "end_ms": char_end} + ) + cursor = index + len(token) + previous_end = end + + for char in text[cursor:]: + if not char.isspace(): + chars.append( + {"char": char, "start_ms": previous_end, "end_ms": previous_end} + ) + return chars + + +async def synth(segment: dict, voice: str, rate: str, build: Path): + check_edge_tts_version() + import edge_tts + root = build / "tts" + root.mkdir(exist_ok=True) + mp3, clock = root / f"{segment['id']}.mp3", root / f"{segment['id']}.json" + contract = {"text": segment["text"], "voice": voice, "rate": rate} + if mp3.exists() or clock.exists(): + if not (mp3.exists() and clock.exists()): + raise RenderError(f"incomplete narration cache: {segment['id']}") + cached = json.loads(clock.read_text(encoding="utf-8")) + if any(cached.get(k) != v for k, v in contract.items()): + raise RenderError( + f"narration cache mismatch for {segment['id']}: edit episode.json " + "and delete tts/.mp3/.json to re-synthesize") + return mp3, align_clock(segment["text"], cached["words"]) + words, content = [], bytearray() + stream = edge_tts.Communicate(segment["text"], voice, rate=rate, boundary="WordBoundary") + async for chunk in stream.stream(): + if chunk["type"] == "audio": + content.extend(chunk["data"]) + elif chunk["type"] == "WordBoundary": + words.append({"char": chunk["text"], + "start_ms": chunk["offset"] // 10000, + "end_ms": (chunk["offset"] + chunk["duration"]) // 10000}) + if not words or not content: + raise RenderError(f"no audio or word boundaries from TTS: {segment['id']}") + mp3.write_bytes(content) + clock.write_text( + json.dumps({**contract, "words": words}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return mp3, align_clock(segment["text"], words) + + +def group_lines(chars: list[dict], max_line: int = MAX_LINE) -> list[list[dict]]: + """Split word-clock chars into subtitle lines; keep latin words unbroken.""" + lines, current = [], [] + for index, char in enumerate(chars): + current.append(char) + nxt = chars[index + 1]["char"] if index + 1 < len(chars) else "" + if char["char"].isascii() and char["char"].isalnum() \ + and nxt.isascii() and nxt.isalnum(): + continue + if (char["char"] in BREAK_AFTER + or len(current) >= max_line + 2 + or (len(current) >= max_line and char["char"] in SOFT_BREAK)): + lines.append(current) + current = [] + if current: + lines.append(current) + return lines + + +def render_segment(segment: dict, mp3: Path, chars: list[dict], build: Path, + feedback: dict | None = None): + from PIL import Image, ImageDraw + src = build / segment["shot"] + if not src.is_file(): + raise RenderError(f"missing real recording: {src}") + start, end = float(segment["start"]), float(segment["end"]) + if not 0 <= start < end <= duration(src) + 0.1: + raise RenderError(f"invalid recording interval: {segment['id']}") + seconds = math.ceil((duration(mp3) + LEAD + TAIL) * FPS) / FPS + frames = round(seconds * FPS) + speed = (end - start) / seconds + if not SPEED_MIN <= speed <= SPEED_MAX: + raise RenderError( + f"recording speed {speed:.2f}x outside {SPEED_MIN}x..{SPEED_MAX}x for " + f"{segment['id']}: shorten the narration or widen the interval") + crop = segment.get("crop", DEFAULT_CROP) + x, y, width, height = crop + vf = (f"crop={width}:{height}:{x}:{y},setpts={seconds / (end - start)}*(PTS-STARTPTS)," + "scale=1800:866:force_original_aspect_ratio=decrease:flags=lanczos," + "eq=brightness=-0.035,pad=1920:1080:(ow-iw)/2:66:color=0x10182b," + f"fps={FPS},tpad=stop_mode=clone:stop_duration={seconds}") + decode = subprocess.Popen( + ["ffmpeg", "-v", "error", "-ss", str(start), "-t", str(end - start), + "-i", str(src), "-vf", vf, "-frames:v", str(frames), + "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = build / f"{segment['id']}.mp4" + encode = subprocess.Popen( + ["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", + "-s", f"{W}x{H}", "-r", str(FPS), "-i", "pipe:0", "-an", "-t", str(seconds), + "-c:v", "libx264", "-preset", "fast", "-crf", "19", "-pix_fmt", "yuv420p", + str(output)], + stdin=subprocess.PIPE, stderr=subprocess.PIPE) + lines = group_lines(chars) + starts = [line[0]["start_ms"] for line in lines] + font, label_font, case_font = find_font(48), find_font(32), find_font(30) + panel_font, panel_head_font = find_font(24), find_font(18) + repro_steps = list((feedback or {}).get("repro") or []) + show_repro = bool(feedback) and segment.get("phase") == "repro" and bool(repro_steps) + case = PHASE_LABELS.get(segment.get("phase", ""), "") if feedback else segment.get("case", "") + severity = SEVERITIES.get((feedback or {}).get("severity", ""), ("", None)) + try: + for frame_index in range(frames): + content = decode.stdout.read(W * H * 3) + if len(content) != W * H * 3: + raise RenderError( + f"recording decoder ended at frame {frame_index}/{frames}: " + "interval beyond the source") + frame = Image.frombytes("RGB", (W, H), content) + draw = ImageDraw.Draw(frame) + label_x = 82 + if severity[1] is not None: + severity_right = 56 + draw.textlength(severity[0], font=case_font) + 28 + draw.rounded_rectangle((56, 12, severity_right, 56), radius=8, fill=severity[1]) + draw.text((56 + 14, 34), severity[0], font=case_font, fill="white", anchor="lm") + label_x = severity_right + 12 + if case: + case_right = label_x + draw.textlength(case, font=case_font) + 28 + draw.rounded_rectangle((label_x - 4, 12, case_right, 56), radius=8, + fill=(37, 99, 235)) + draw.text((label_x + 10, 34), case, font=case_font, fill="white", anchor="lm") + label_x = case_right + 12 + draw.text((label_x, 34), segment["label"], font=label_font, + fill=(190, 206, 230), anchor="lm") + if show_repro: + draw_repro_panel(draw, repro_steps, panel_font, panel_head_font) + t = frame_index / FPS * 1000 - LEAD * 1000 + index = bisect.bisect_right(starts, t) - 1 + if index >= 0 and t <= lines[index][-1]["end_ms"] + 180: + text = "".join(c["char"] for c in lines[index]) + draw.text((W / 2, 1008), text, font=font, fill="white", anchor="mm") + encode.stdin.write(frame.tobytes()) + finally: + decode.stdout.close() + encode.stdin.close() + decode_error = decode.stderr.read().decode() + encode_error = encode.stderr.read().decode() + if decode.wait() or encode.wait(): + raise RenderError(f"video encoding failed:\n{decode_error}\n{encode_error}") + run(["ffmpeg", "-y", "-v", "error", "-i", str(mp3), + "-af", "adelay=150:all=1,apad", "-t", str(seconds), + "-ar", "44100", "-ac", "2", "-c:a", "pcm_s16le", + str(output.with_suffix(".wav"))]) + print(f"RENDERED {segment['id']}: {seconds:.2f}s {speed:.2f}x", flush=True) + return output, seconds + + +def draw_repro_panel(draw, steps: list, font, head_font) -> None: + visible = steps[:REPRO_PANEL_LIMIT] + omitted = len(steps) - len(visible) + width, line_h = 520, 34 + height = 62 + line_h * len(visible) + (line_h if omitted else 0) + 10 + right, top = W - 56, 92 + left = right - width + draw.rounded_rectangle((left, top, right, top + height), radius=10, fill=(16, 26, 46)) + draw.text((left + 18, top + 22), "REPRO STEPS", font=head_font, + fill=(150, 176, 214), anchor="lm") + y = top + 52 + for index, step in enumerate(visible): + text = f"{index + 1}. {step}" + if len(text) > 34: + text = text[:33] + "..." + draw.text((left + 18, y), text, font=font, fill=(236, 242, 250), anchor="lm") + y += line_h + if omitted: + draw.text((left + 18, y), f"+{omitted} more", font=head_font, + fill=(150, 176, 214), anchor="lm") + + +def loudnorm_finalize(raw: Path, final: Path) -> None: + """Two-pass loudnorm (single pass drifts 1-1.5 LU); video stream copied.""" + probe = subprocess.run( + ["ffmpeg", "-i", str(raw), "-af", + "loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"], + capture_output=True, text=True) + if probe.returncode: + raise RenderError(f"loudness measurement failed:\n{probe.stderr[-1000:]}") + meas = json.loads(probe.stderr[probe.stderr.rindex("{"):probe.stderr.rindex("}") + 1]) + af = ("loudnorm=I=-14:TP=-1.5:LRA=11:" + f"measured_I={meas['input_i']}:measured_TP={meas['input_tp']}:" + f"measured_LRA={meas['input_lra']}:measured_thresh={meas['input_thresh']}:linear=true") + run(["ffmpeg", "-y", "-i", str(raw), "-af", af, "-ar", "44100", "-ac", "2", + "-c:a", "aac", "-b:a", "192k", "-c:v", "copy", str(final)]) + + +# ---------------------------------------------------------------- quality gate + +def parse_detects(log: str, key: str) -> list[dict]: + """Parse ffmpeg silencedetect/blackdetect/freezedetect key=value lines.""" + events, current = [], {} + for line in log.splitlines(): + for match in re.finditer(r"(\w+)_start: ([\d.]+)", line): + current = {key: float(match.group(2))} + for match in re.finditer(r"(\w+)_end: ([\d.]+)", line): + current["end"] = float(match.group(2)) + if "start" in current: + events.append(current) + current = {} + return events + + +def quality_gate(video: Path, work: Path) -> dict: + """Deterministic delivery gate; hard failures make status=fail.""" + work.mkdir(parents=True, exist_ok=True) + hard, warnings, notices, checks = [], [], [], {} + info = probe_json(video) + streams = info.get("streams") or [] + fmt = info.get("format") or {} + total = float(fmt.get("duration") or 0) + + video_stream = next((s for s in streams if s.get("codec_type") == "video"), None) + audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None) + if video_stream is None: + hard.append({"code": "M100", "message": "no video stream"}) + else: + wh = (video_stream.get("width"), video_stream.get("height")) + if wh != (W, H): + hard.append({"code": "M101", "message": f"resolution {wh}, expected {(W, H)}"}) + rate = video_stream.get("avg_frame_rate") or "0/1" + num, _, den = rate.partition("/") + fps = float(num) / float(den or 1) + if abs(fps - FPS) > 0.01: + hard.append({"code": "M102", "message": f"frame rate {fps:.3f}, expected {FPS}"}) + if video_stream.get("codec_name") != "h264": + hard.append({"code": "M103", "message": f"codec {video_stream.get('codec_name')}, expected h264"}) + checks["spec"] = {"wh": list(wh), "fps": round(fps, 3), + "codec": video_stream.get("codec_name")} + if audio_stream is None: + hard.append({"code": "E101", "message": "no audio stream: render with narration"}) + elif audio_stream.get("codec_name") != "aac": + warnings.append({"code": "E102", + "message": f"audio codec {audio_stream.get('codec_name')}, expected aac"}) + + if total > 0: + bits = int(fmt.get("size") or 0) * 8 + kbps = bits / total / 1000 + checks["bitrate_kbps"] = round(kbps) + if kbps < 1000: + notices.append({ + "code": "M111", + "message": ( + f"bitrate density {kbps:.0f} kbps is low for dense 1080p " + "content; inspect text at 1:1 before publishing" + ), + }) + + if audio_stream is not None: + ebur = subprocess.run( + ["ffmpeg", "-i", str(video), "-filter_complex", + "loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"], + capture_output=True, text=True) + if ebur.returncode == 0 and "{" in ebur.stderr: + meas = json.loads(ebur.stderr[ebur.stderr.rindex("{"):ebur.stderr.rindex("}") + 1]) + integrated = float(meas.get("input_i", "0") or 0) + checks["loudness_lufs"] = integrated + if not -30 <= integrated <= -5: + hard.append({"code": "L101", + "message": f"integrated loudness {integrated:.1f} LUFS outside [-30,-5]"}) + filters = { + "black": (["-vf", "blackdetect=d=0.5:pix_th=0.05", "-an"], 1.0, 0.3, "B101"), + "freeze": (["-vf", "freezedetect=n=-60dB:d=0.5", "-an"], 2.0, 0.8, "T301"), + "silence": (["-af", "silencedetect=n=-35dB:d=2", "-vn"], 5.0, 2.0, "E301"), + } + for name, (args, hard_s, warn_s, code) in filters.items(): + scan = subprocess.run( + ["ffmpeg", "-v", "info", "-i", str(video), *args, "-f", "null", "-"], + capture_output=True, text=True) + spans = parse_detects(scan.stderr, "start") + lengths = [s["end"] - s["start"] for s in spans if "end" in s] + checks[f"{name}_spans"] = [ + {"start": round(s["start"], 2), "end": round(s.get("end", 0), 2)} + for s in spans] + if lengths: + longest = max(lengths) + if longest >= hard_s: + hard.append({"code": code, + "message": f"longest {name} span {longest:.1f}s >= {hard_s}s at " + + ", ".join(f"{s['start']:.1f}" for s in spans + if "end" in s and s["end"] - s["start"] == longest)}) + elif longest >= warn_s: + warnings.append({"code": code, + "message": f"longest {name} span {longest:.1f}s"}) + if name == "freeze" and total > 0 and sum(lengths) / total >= 0.20: + hard.append({"code": "T304", + "message": f"frozen frames are {sum(lengths)/total:.0%} of the video; cut waiting segments or raise playback speed"}) + status = "fail" if hard else "pass" + score = max(0, 100 - 10 * len(hard) - 2 * len(warnings)) + report = {"profile": PROFILE, "status": status, "video": video.name, + "video_sha256": sha256(video), "duration_s": round(total, 3), + "hard_failures": hard, "warnings": warnings, "notices": notices, + "score": {"total": score}, "checks": checks} + (work / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return report + + +def write_feedback_report(build: Path, issue: dict, segments: list[dict], report: dict) -> Path: + payload = { + "kind": "feedback", + "issue": issue, + "artifact": { + "file": "screen_demo.mp4", + "duration_s": report["duration_s"], + "sha256": report["video_sha256"], + }, + "gate": { + "status": report["status"], + "profile": report["profile"], + "report": "quality_gate/report.json", + }, + "segments": [ + { + "id": segment["id"], + "phase": segment.get("phase"), + "shot": segment["shot"], + "start": segment["start"], + "end": segment["end"], + } + for segment in segments + ], + } + path = build / "feedback.json" + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def format_clock(seconds: float) -> str: + total = int(round(float(seconds) * 1000)) + minutes, remainder = divmod(total, 60_000) + secs, millis = divmod(remainder, 1000) + return f"{minutes:02d}:{secs:02d}.{millis:03d}" + + +def markdown_cell(value: object) -> str: + return str(value).replace("|", "\\|").replace("\n", " ").strip() + + +def feedback_markdown(issue: dict, segments: list[dict], timeline: list[dict], + report: dict) -> str: + issue_id = str(issue.get("id") or "").strip() + heading = f"{issue_id} {issue['title']}".strip() + severity = issue["severity"] + severity_label = SEVERITIES[severity][0] + phases = { + segment["id"]: PHASE_LABELS.get(segment.get("phase", ""), "") + for segment in segments + } + rows = [] + for item in timeline: + rows.append( + "| {phase} | {start}-{end} | `{source}` | {src_start:.2f}-{src_end:.2f}s |".format( + phase=phases.get(item["id"], "") or "-", + start=format_clock(item["start"]), + end=format_clock(item["end"]), + source=markdown_cell(item["source"]), + src_start=float(item["source_start"]), + src_end=float(item["source_end"]), + ) + ) + repro = issue.get("repro") or [] + steps = "\n".join(f"{index + 1}. {step}" for index, step in enumerate(repro)) + environment = issue.get("environment") + lines = [ + f"# {heading}", + "", + "> Draft generated by render.py from episode.json. Review it and add impact or " + "business context before posting.", + "", + f"- Severity: `{severity}` ({severity_label})", + ] + if environment: + lines.append(f"- Environment: {environment}") + lines.extend([ + f"- Artifact: `screen_demo.mp4` ({report['duration_s']:.3f}s, " + f"SHA256 `{report['video_sha256']}`)", + f"- Quality gate: `{report['status']}` ({report['profile']}, " + f"score {report['score']['total']})", + "", + "## Expected", + "", + issue["expected"], + "", + "## Actual", + "", + issue["actual"], + "", + "## Reproduction", + "", + steps or "No steps supplied.", + "", + "## Segments", + "", + "| Phase | In video | Source | Source range |", + "| --- | --- | --- | --- |", + *rows, + "", + "## Proven by this artifact", + "", + "- Every segment is a continuous take from the recording listed above.", + "- The quality gate is bound to the artifact SHA256; the posted file must match it.", + "", + "## Not verified", + "", + "- Whether the defect is fixed in another build.", + "- Whether other environments, accounts or data sets reproduce it.", + "", + ]) + return "\n".join(lines) + + +def write_feedback_markdown(build: Path, issue: dict, segments: list[dict], + timeline: list[dict], report: dict) -> Path: + path = build / "feedback.generated.md" + path.write_text(feedback_markdown(issue, segments, timeline, report), encoding="utf-8") + return path + + +def feedback_binding(feedback: dict, markdown_path: Path) -> dict: + return { + "report": "feedback.json", + "sha256": feedback_content_sha256(feedback), + "markdown": markdown_path.name, + "markdown_sha256": sha256(markdown_path), + } + + +def bind_feedback_evidence(report_path: Path, report: dict, feedback_path: Path, + markdown_path: Path) -> dict: + feedback = json.loads(feedback_path.read_text(encoding="utf-8")) + binding = feedback_binding(feedback, markdown_path) + report["feedback"] = binding + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return binding + + +def cmd_render(build: Path) -> int: + check_tools() + check_edge_tts_version() + check_pillow() + font_path() + episode_path = build / "episode.json" + if not episode_path.is_file(): + raise RenderError(f"missing {episode_path}") + episode = json.loads(episode_path.read_text(encoding="utf-8")) + segments = episode.get("segments") or [] + if not segments or len({s["id"] for s in segments}) != len(segments): + raise RenderError("missing segments or duplicate segment IDs") + rule_errors = feedback_errors(episode, segments) + if rule_errors: + raise RenderError("; ".join(rule_errors)) + feedback = episode.get("issue") if episode.get("intent") == "feedback" else None + for key in ("voice",): + if not episode.get(key): + raise RenderError(f"episode.json missing required field: {key}") + for segment in segments: + for key in ("id", "label", "shot", "start", "end", "text"): + if key not in segment: + raise RenderError(f"segment missing field {key!r}: {segment.get('id')}") + if not isinstance(segment["text"], str) or "\n" in segment["text"]: + raise RenderError(f"segment text must be one continuous line: {segment['id']}") + check_narration(segment["text"], segment["id"]) + + tracks, timeline, offset = [], [], 0.0 + for segment in segments: + mp3, chars = asyncio.run( + synth(segment, episode["voice"], episode.get("rate", "+5%"), build)) + clip, seconds = render_segment(segment, mp3, chars, build, feedback) + tracks.append(clip) + timeline.append({"id": segment["id"], "start": round(offset, 3), + "end": round(offset + seconds, 3), "source": segment["shot"], + "source_start": segment["start"], "source_end": segment["end"]}) + offset += seconds + + concat = build / "clips.txt" + concat.write_text("".join(f"file '{p.name}'\n" for p in tracks), encoding="utf-8") + picture = build / "picture.mp4" + run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", str(concat), "-c", "copy", "-movflags", "+faststart", str(picture)]) + voice_list = build / "voice-clips.txt" + voice_list.write_text( + "".join(f"file '{p.with_suffix('.wav').name}'\n" for p in tracks), + encoding="utf-8", + ) + voice = build / "voice.wav" + run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", str(voice_list), "-c:a", "pcm_s16le", str(voice)]) + assembled = build / "assembled.mp4" + run(["ffmpeg", "-y", "-v", "error", "-i", str(picture), "-i", str(voice), + "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", + "-ar", "44100", "-ac", "2", "-t", str(offset), str(assembled)]) + final = build / "screen_demo.mp4" + loudnorm_finalize(assembled, final) + if abs(duration(final) - offset) > 0.15: + raise RenderError(f"final duration {duration(final):.3f}s != expected {offset:.3f}s") + run(["ffmpeg", "-y", "-v", "error", "-ss", "2", "-i", str(final), + "-frames:v", "1", str(build / "cover.png")]) + for index, item in enumerate(timeline): + run(["ffmpeg", "-y", "-v", "error", + "-ss", str((item["start"] + item["end"]) / 2), "-i", str(final), + "-frames:v", "1", str(build / f"preview_{index:02d}.png")]) + (build / "timeline.json").write_text( + json.dumps(timeline, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + report = quality_gate(final, build / "quality_gate") + if feedback is not None: + feedback_report_path = write_feedback_report(build, feedback, segments, report) + markdown_path = write_feedback_markdown(build, feedback, segments, timeline, report) + binding = bind_feedback_evidence( + build / "quality_gate" / "report.json", + report, + feedback_report_path, + markdown_path, + ) + print( + f"FEEDBACK {feedback_report_path} {markdown_path} " + f"sha256={binding['sha256']} markdown_sha256={binding['markdown_sha256']}", + flush=True, + ) + print(f"COMPLETE {final} duration={duration(final):.3f}s " + f"gate={report['status']} score={report['score']['total']}", flush=True) + for notice in report["notices"]: + print(f"NOTE {notice['code']}: {notice['message']}", flush=True) + if report["status"] != "pass": + for failure in report["hard_failures"]: + print(f"HARD FAIL {failure['code']}: {failure['message']}", flush=True) + return 1 + return 0 + + +def cmd_gate(video: Path, work: Path) -> int: + check_tools() + if not video.is_file(): + raise RenderError(f"missing video: {video}") + report = quality_gate(video, work) + print(f"GATE={report['status'].upper()} video={video} " + f"score={report['score']['total']} profile={report['profile']} " + f"report={work / 'report.json'}", flush=True) + for failure in report["hard_failures"]: + print(f"HARD FAIL {failure['code']}: {failure['message']}", flush=True) + for warning in report["warnings"]: + print(f"WARN {warning['code']}: {warning['message']}", flush=True) + for notice in report["notices"]: + print(f"NOTE {notice['code']}: {notice['message']}", flush=True) + return 0 if report["status"] == "pass" else 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + render_parser = sub.add_parser("render", help="render episode.json and run the gate") + render_parser.add_argument("build", type=Path, help="build directory holding episode.json") + gate_parser = sub.add_parser("gate", help="delivery gate for any mp4 (attachment gate)") + gate_parser.add_argument("--video", type=Path, required=True) + gate_parser.add_argument("--work", type=Path, required=True, + help="output directory for report.json") + args = parser.parse_args() + try: + if args.command == "render": + return cmd_render(args.build.expanduser().resolve()) + return cmd_gate(args.video.expanduser().resolve(), args.work.expanduser().resolve()) + except RenderError as exc: + print(f"FAIL: {exc}", file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/delivery-video/skills/delivery-video/scripts/verify_delivery.py b/external_plugins/delivery-video/skills/delivery-video/scripts/verify_delivery.py new file mode 100644 index 00000000..1f4546a5 --- /dev/null +++ b/external_plugins/delivery-video/skills/delivery-video/scripts/verify_delivery.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Verify a rendered screen_demo delivery artifact and its quality evidence. + +Usage: verify_delivery.py [--require-critic] + +Checks the final mp4 spec (h264/1920x1080/30fps/aac), decode integrity, the +quality-gate report binding (video_sha256), per-shot evidence files and the +optional independent visual review. FAIL means fix the material. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from render import feedback_content_sha256 # noqa: E402 + + +class CheckError(Exception): + pass + + +def run_json(args: list[str]) -> dict: + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + raise CheckError(f"could not run {args[0]}: {exc}") from exc + if result.returncode: + raise CheckError(f"ffprobe failed: {result.stderr.strip()[-500:]}") + return json.loads(result.stdout) + + +def probe(path: Path) -> dict: + return run_json([ + "ffprobe", "-v", "error", + "-show_entries", + "stream=codec_name,codec_type,width,height,r_frame_rate:format=duration,size", + "-of", "json", str(path), + ]) + + +def parse_fps(value: str) -> float: + numerator, _, denominator = value.partition("/") + divisor = float(denominator or 1) + if divisor == 0: + raise CheckError(f"invalid frame rate: {value}") + return float(numerator) / divisor + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CheckError(f"invalid JSON in {path}: {exc}") from exc + + +def check_shot(prefix: str, shot: Path, evidence_path: Path, errors: list[str]) -> None: + if not shot.is_file(): + errors.append(f"{prefix}: shot file is missing: {shot.name}") + return + try: + evidence = load_json(evidence_path) + except CheckError as exc: + errors.append(str(exc)) + return + if not isinstance(evidence, dict): + errors.append(f"{prefix}: evidence must be an object: {evidence_path.name}") + return + kind = evidence.get("kind") + if kind not in {"cdp-screencast", "browser-demo", "demo-stage-recording"}: + errors.append(f"{prefix}: evidence kind {evidence.get('kind')!r} is not a browser/stage recording") + return + port = evidence.get("port") + if port is not None and (not isinstance(port, int) or isinstance(port, bool) or port <= 0): + errors.append(f"{prefix}: evidence port {port!r} must be a positive integer") + if kind == "demo-stage-recording": + video = evidence.get("video") + if not isinstance(video, dict): + errors.append(f"{prefix}: demo-stage evidence lacks video block") + return + if evidence.get("status") != "passed": + errors.append(f"{prefix}: demo-stage evidence status {evidence.get('status')!r} != 'passed'") + if video.get("sha256") != sha256(shot): + errors.append(f"{prefix}: evidence sha256 does not match {shot.name}") + if int(video.get("frames") or 0) < 1: + errors.append(f"{prefix}: evidence has no captured frames") + try: + recorded = float(video.get("duration_s")) + except (TypeError, ValueError): + errors.append(f"{prefix}: demo-stage duration unreadable") + return + else: + if evidence.get("video") not in (None, shot.name): + errors.append( + f"{prefix}: evidence video {evidence.get('video')!r} does not match {shot.name}" + ) + if evidence.get("sha256") != sha256(shot): + errors.append(f"{prefix}: evidence sha256 does not match {shot.name}") + if int(evidence.get("frames") or 0) < 1: + errors.append(f"{prefix}: evidence has no captured frames") + try: + recorded = float(evidence.get("video_s")) + except (TypeError, ValueError): + errors.append(f"{prefix}: evidence duration unreadable") + return + try: + info = probe(shot) + stream = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), {}) + size = [stream.get("width"), stream.get("height")] + actual = float(info["format"]["duration"]) + except (CheckError, KeyError, TypeError, ValueError) as exc: + errors.append(f"{prefix}: evidence duration/size unreadable: {exc}") + return + if kind != "demo-stage-recording": + if size != [1920, 1080]: + errors.append(f"{prefix}: browser shot must be 1920x1080, got {size[0]}x{size[1]}") + if evidence.get("size") != size: + errors.append(f"{prefix}: evidence size {evidence.get('size')!r} != shot {size}") + if abs(actual - recorded) > 0.2: + errors.append(f"{prefix}: evidence duration {recorded:.2f}s != shot {actual:.2f}s") + + +def check_shot_evidence(build: Path, errors: list[str], warnings: list[str]) -> int: + """Per-shot evidence check: browser/stage shots need a matching evidence file.""" + episode_path = build / "episode.json" + if not episode_path.is_file(): + errors.append(f"missing {episode_path}") + return 0 + try: + episode = load_json(episode_path) + except CheckError as exc: + errors.append(str(exc)) + return 0 + segments = episode.get("segments") if isinstance(episode, dict) else None + if not isinstance(segments, list) or not segments: + errors.append("episode.json has no segments") + return 0 + + checked = 0 + referenced: set[Path] = set() + for index, segment in enumerate(segments): + prefix = segment.get("id") or f"segments[{index}]" + shot_name = segment.get("shot") + if not isinstance(shot_name, str) or not shot_name: + errors.append(f"{prefix}: shot must be a relative path") + continue + shot = (build / shot_name).resolve() + try: + shot.relative_to(build) + except ValueError: + errors.append(f"{prefix}: shot resolves outside the build directory") + continue + evidence_path = shot.with_suffix(".evidence.json") + if evidence_path.is_file(): + referenced.add(evidence_path.resolve()) + check_shot(prefix, shot, evidence_path, errors) + checked += 1 + elif segment.get("source") in ("browser", "demo-stage"): + errors.append( + f"{prefix}: source={segment.get('source')} but {evidence_path.name} is missing" + ) + elif not shot.is_file(): + errors.append(f"{prefix}: shot file is missing: {shot.name}") + + for evidence_path in sorted(build.rglob("*.evidence.json")): + if evidence_path.resolve() not in referenced: + warnings.append(f"{evidence_path.name}: evidence not referenced by episode.json") + return checked + + +def check_feedback_report(build: Path, digest: str, gate: dict) -> list[str]: + """Check feedback.json and feedback.generated.md against the gate binding.""" + errors: list[str] = [] + feedback_path = build / "feedback.json" + if not feedback_path.is_file(): + return [f"feedback episode is missing {feedback_path.name}"] + try: + feedback = load_json(feedback_path) + except CheckError as exc: + return [str(exc)] + if not isinstance(feedback, dict): + return [f"feedback report must be an object: {feedback_path}"] + if (feedback.get("artifact") or {}).get("sha256") != digest: + errors.append("feedback report is not bound to this artifact (sha256 mismatch)") + if (feedback.get("gate") or {}).get("status") != "pass": + errors.append("feedback report gate status is not 'pass'") + phases = [ + item.get("phase") + for item in (feedback.get("segments") or []) + if isinstance(item, dict) + ] + if "actual" not in phases: + errors.append("feedback report has no 'actual' segment") + + binding = gate.get("feedback") if isinstance(gate, dict) else None + has_binding = isinstance(binding, dict) and bool(binding) + if not has_binding: + errors.append( + "quality report has no feedback report binding " + "(quality_gate/report.json.feedback is missing); re-render the feedback episode" + ) + binding = {} + if binding.get("report") not in (None, feedback_path.name): + errors.append( + "quality report feedback binding points at " + f"{binding.get('report')!r}, expected {feedback_path.name!r}" + ) + markdown_name = binding.get("markdown") + if not isinstance(markdown_name, str) or not markdown_name: + markdown_name = "feedback.generated.md" + markdown_path = (build / markdown_name).resolve() + try: + markdown_path.relative_to(build) + except ValueError: + errors.append( + "feedback markdown path resolves outside the build directory: " + f"{markdown_name}" + ) + markdown_path = build / "feedback.generated.md" + if not markdown_path.is_file(): + errors.append(f"feedback markdown is missing: {markdown_path.name}") + elif has_binding and binding.get("markdown_sha256") != sha256(markdown_path): + errors.append( + "feedback markdown was edited after rendering " + f"({markdown_path.name} sha256 mismatch)" + ) + if has_binding and binding.get("sha256") != feedback_content_sha256(feedback): + errors.append( + "feedback report content was edited after rendering " + "(feedback.json sha256 mismatch); re-render or restore feedback.json" + ) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build", type=Path, help="screen_demo[-] build directory") + parser.add_argument( + "--require-critic", + action="store_true", + help="fail when visual-critic.json is missing", + ) + args = parser.parse_args() + + build = args.build.expanduser().resolve() + video = build / "screen_demo.mp4" + errors: list[str] = [] + warnings: list[str] = [] + + missing_tools = [ + name for name in ("ffmpeg", "ffprobe") if shutil.which(name) is None + ] + if missing_tools: + return fail( + [ + f"{name} is not on PATH; install ffmpeg and make sure both binaries are available" + for name in missing_tools + ], + warnings, + ) + + if not video.is_file() or video.stat().st_size == 0: + return fail([f"missing or empty artifact: {video}"], warnings) + + try: + data = probe(video) + except (CheckError, json.JSONDecodeError) as exc: + return fail([str(exc)], warnings) + streams = data.get("streams") or [] + video_stream = next((item for item in streams if item.get("codec_type") == "video"), None) + audio_stream = next((item for item in streams if item.get("codec_type") == "audio"), None) + if video_stream is None: + errors.append("artifact has no video stream") + else: + if video_stream.get("codec_name") != "h264": + errors.append(f"expected h264 video, got {video_stream.get('codec_name')}") + if (video_stream.get("width"), video_stream.get("height")) != (1920, 1080): + errors.append( + "expected 1920x1080, got " + f"{video_stream.get('width')}x{video_stream.get('height')}" + ) + fps = parse_fps(str(video_stream.get("r_frame_rate", "0/1"))) + if abs(fps - 30) > 0.01: + errors.append(f"expected 30fps, got {fps:.3f}") + if audio_stream is None: + errors.append("artifact has no audio stream") + elif audio_stream.get("codec_name") != "aac": + warnings.append(f"audio codec is {audio_stream.get('codec_name')}, expected aac") + + duration = data.get("format", {}).get("duration") + if duration in (None, "N/A") or float(duration) <= 1: + errors.append(f"invalid duration: {duration}") + try: + decode = subprocess.run( + ["ffmpeg", "-v", "error", "-i", str(video), "-f", "null", "-"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + return fail([f"could not run ffmpeg: {exc}"], warnings) + if decode.returncode: + errors.append(f"decode failed: {decode.stderr.strip()[-500:]}") + + report_path = build / "quality_gate" / "report.json" + gate: dict = {} + notices: list[str] = [] + if not report_path.is_file(): + errors.append(f"missing quality report: {report_path}") + else: + try: + gate = load_json(report_path) + except CheckError as exc: + errors.append(str(exc)) + if not isinstance(gate, dict): + errors.append(f"quality report must be an object: {report_path}") + gate = {} + if gate.get("status") != "pass": + errors.append(f"quality gate status is {gate.get('status')!r} in {report_path}") + hard = gate.get("hard_failures") or [] + if hard: + errors.append(f"quality gate has {len(hard)} hard failure(s) in {report_path}") + if gate.get("profile") != "delivery-v1": + errors.append( + f"quality report profile is {gate.get('profile')!r}; " + "rerun the gate: render.py gate --video --work " + ) + score = gate.get("score") or {} + warnings.extend( + f"quality warning {item.get('code')}: {item.get('message')}" + for item in (gate.get("warnings") or []) + ) + notices.extend( + f"{item.get('code')}: {item.get('message')}" + for item in (gate.get("notices") or []) + ) + critic_path = build / "visual-critic.json" + digest = sha256(video) + if gate and gate.get("video_sha256") != digest: + errors.append( + "quality report is not bound to this artifact " + "(video_sha256 missing or mismatched); rerun the gate" + ) + episode_path = build / "episode.json" + if episode_path.is_file(): + try: + episode = load_json(episode_path) + except CheckError as exc: + errors.append(str(exc)) + episode = {} + if isinstance(episode, dict) and episode.get("intent") == "feedback": + errors.extend(check_feedback_report(build, digest, gate)) + if critic_path.is_file(): + try: + critic = load_json(critic_path) + except CheckError as exc: + errors.append(str(exc)) + critic = {} + if not isinstance(critic, dict): + errors.append(f"visual critic must be an object: {critic_path}") + critic = {} + if critic.get("status") != "pass": + errors.append(f"visual critic status is {critic.get('status')!r}") + if critic.get("critical"): + errors.append(f"visual critic has {len(critic['critical'])} critical item(s)") + if critic.get("video_sha256") != digest: + errors.append("visual critic video_sha256 does not match the current artifact") + elif args.require_critic: + errors.append(f"missing independent visual review: {critic_path}") + + shot_count = check_shot_evidence(build, errors, warnings) + for notice in notices: + print(f"NOTE: {notice}") + for warning in warnings: + print(f"WARN: {warning}") + if errors: + return fail(errors, []) + score = (gate.get("score") or {}).get("total") + print( + f"DELIVERY=PASS video={video} duration={float(duration):.3f}s " + f"sha256={digest} score={score} profile={gate.get('profile')} " + f"shots={shot_count} report={report_path}" + ) + return 0 + + +def fail(errors: list[str], warnings: list[str]) -> int: + for warning in warnings: + print(f"WARN: {warning}") + for error in errors: + print(f"FAIL: {error}") + print(f"DELIVERY=FAIL issues={len(errors)}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/marketplace.json b/marketplace.json index c7f09a06..66c7a5d9 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1443,6 +1468,48 @@ "mcp", "verification" ] + }, + { + "name": "delivery-video", + "source": "./external_plugins/delivery-video", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/delivery-video/icon.png", + "description": "Record, render and gate trustworthy delivery demo videos — or defect/feedback report videos — of real, working software: real screen recordings, edge-tts narration, word-synced subtitles and a deterministic quality gate.", + "description_i18n": { + "en": "Record, render and gate trustworthy delivery demo videos — or defect/feedback report videos — of real, working software: real screen recordings, edge-tts narration, word-synced subtitles and a deterministic quality gate.", + "zh-CN": "把真实运行的功能录成可交付的演示视频,或把缺陷录成可追溯的问题反馈视频:真实录屏、edge-tts 旁白、逐字对齐字幕与确定性质量闸,产出 1920x1080 成片与完整证据链。" + }, + "version": "1.1.2", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "category": "productivity", + "keywords": [ + "delivery", + "demo-video", + "bug-report", + "screen-recording", + "acceptance", + "quality-gate", + "edge-tts", + "ffmpeg" + ], + "examplePrompts": [ + "Record a 90-second delivery demo video of the feature we just finished", + "Record this defect and turn it into a feedback video with repro steps", + "Gate this build directory and tell me what still fails" + ], + "examplePrompts_i18n": { + "zh-CN": [ + "把刚做完的这个功能录成 90 秒交付演示视频", + "把这个 bug 录成带复现步骤的问题反馈视频", + "把这个 build 目录过一遍质量闸,还差什么" + ] + }, + "displayName": "Delivery Video", + "displayName_i18n": { + "zh-CN": "交付视频" + } } ] -} \ No newline at end of file +}