diff --git a/samples/bedroom/.gitignore b/samples/bedroom/.gitignore
new file mode 100644
index 0000000..3912fd9
--- /dev/null
+++ b/samples/bedroom/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+dist/
+public/assets/*
+!public/assets/manifest.json
+evidence/*
+!evidence/.gitkeep
+__pycache__/
diff --git a/samples/bedroom/README.md b/samples/bedroom/README.md
new file mode 100644
index 0000000..3f1613d
--- /dev/null
+++ b/samples/bedroom/README.md
@@ -0,0 +1,55 @@
+HEL-711 最小动态校对小样,基于 main `1e9b08a`。仅在 `samples/bedroom` 内工作;不连接人格、记忆、天气或语音。采用 HEL-708 的环境/人物/交谈分层边界,以及 CG-001~026 的当前需求。旧演示不变。
+
+## 本地复核
+
+1. 将本卡附件 `HEL-711-review-bundle.zip` 中的 `public/assets` 解压到此目录对应位置。
+2. `npm ci --ignore-scripts --no-audit --no-fund`,沿用旧前端分支的 package.json/package-lock.json/tsconfig.json,无新依赖。
+3. `npm run build`(包含严格 TypeScript 检查)。
+4. `npm run dev -- --port 5173`,本机浏览器打开终端输出的地址。
+5. `python3 tools/browser_check.py` 使用已安装的 Playwright/Chromium,自动启停本机测试服务并保存证据。此运行机需要给该命令设置 `LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu`,复用了已有浏览器库,未安装系统依赖。
+
+素材不进 Git,缺失时页面明确提示;manifest 在 Git 中保存来源、原图 SHA256、坐标、编码和问题。附件内含完整 assets、已构建 dist、浏览器连续录屏、原始试片、透明试片、分层校对图及生成工作流/日志。
+
+## 代码与边界
+
+- `src/main.ts`:一个持久 video 元素驱动透明 canvas;环境按钮只改环境亮度,不重建/seek 视频。人物、前景遮挡、接触阴影独立开关;静态近景不替代真实交谈。
+- `src/tokens.css`:原样复用旧前端唯一 token 表。`style.css` 为独立工程校对台,无第二套同类组件。
+- `tools/extract_layers.py`:读取现有 SAM vit-b 模型的原始 modeling API,避免可选 predictor 的 OpenCV 依赖;不伪造 cv2,不下载。原图 RGB 保留,只生成 alpha;封闭孔洞填充、0.65px 羽化。
+- `tools/contact_layers.py`:从原入景图提取毯子前景,另建可关闭的暂定接触阴影;未宣称物理阴影校准完成。
+- `tools/generate_once.py`:原 ComfyUI venv + 原生 Wan 节点,单片段、离线、自定义节点禁用。临时服务器仅监听回环 18189,finally 终止自己启动的精确 PID。不修改现有服务、系统 Python、驱动或模型路径配置。
+- `tools/matte_video.py`:49 帧逐帧 SAM 分割,原有 PyAV 编码 VP9 alpha。原始视频未抠像的背景不用于浏览器人物合成。
+- `tools/browser_check.py`:真实 Chromium 检查、三种视口截图和可下载连续录屏。页面隐藏的事件处理做了合成 visibilitychange 检查;真实系统切后台/锁屏仍待体验环境手测。
+
+## 素材处理命令
+
+处理目录:`.12:/media/lucian/Downloads/ComfyUI/output/hel711-task`,输入复制到其 `assets`,脚本放此目录;没有改变现有项目输入。原图由 Multica 鉴权 attachment download 获取。运行时使用原 `/media/lucian/Downloads/ComfyUI/venv/bin/python`。
+
+```bash
+python extract_layers.py assets \
+ --sam-source /media/lucian/Downloads/ComfyUI/custom_nodes/was-node-suite-comfyui/repos/SAM/segment_anything \
+ --checkpoint /media/lucian/Downloads/ComfyUI/models/sams/sam_vit_b_01ec64.pth
+python generate_once.py
+python matte_video.py
+```
+
+回传处理后的素材到本地 `public/assets`,运行 `python3 tools/contact_layers.py`。手工蒙版和坐标均保留在脚本中。`workflow.json` 是本次实际运行的请求,最终脚本仅补了目录创建/日志关闭等清理逻辑;日志和 history 可交叉核对。执行新试片前必须重新检查显存和端口;禁止停止已有服务腾资源。
+
+## 当前实测与未通过项
+
+当前结果见 `REPORT.md`,属于可审核候选,不代表 D1–D6 全部通过。图层是真实独立合成;没有用重生成整幅图代替分层。近景仍用已确认 v2 静态方向图;没有真人语音或口型。
+
+## HEL-712 返工复现
+
+本轮只补原动作的真实回程,并处理书本/手部蒙版;环境、机位、比例、UI token 与静态近景不变。完整返工包中的 `public/assets` 替换旧素材后执行同一构建/浏览器检查。
+
+已有 ComfyUI 环境中,保留原片在 `output/hel711-task/reading_00001_.webm`、原透明片在同目录 `reading-alpha.webm`:
+
+```bash
+python generate_return.py
+python stabilize_loop.py
+python measure_loop.py
+```
+
+脚本放在 `output/hel712-task` 并附同目录 `extract_layers.py`。生成器只用已有原生 `WanFirstLastFrameToVideo` 节点与原有权重,以原片 f48、f0 为两端,seed 712,只生成一条49帧回程。服务器仍仅临时监听回环18189;主程序收齐结果后停止精确子进程。`stabilize_loop.py` 读取原49帧 alpha,新增49帧按书本/衣袖补充分割提示;只在原512坐标 y175–330 对动作对齐的5帧有符号距离去掉最大/最小值后取均值稳定,RGB不做时域融合,原发丝区域不重新分割。原始两段按顺序拼成98帧,接点48→49、97→0,无倒放/淡化/冻结/短窗口裁切。
+
+`measure_loop.py` 输出固定坐标口径的逐对差异、蒙版统计、动作补偿残差、接头与手部证据图;数值不替代目验。`browser_check.py` 先在真实Chromium完整播放至少3周期,再做原交互与三视口复测。真实后台/锁屏及Safari/iOS仍未测。
diff --git a/samples/bedroom/REPORT.md b/samples/bedroom/REPORT.md
new file mode 100644
index 0000000..c60e1bd
--- /dev/null
+++ b/samples/bedroom/REPORT.md
@@ -0,0 +1,52 @@
+HEL-712:原阅读动作循环与书本/手部蒙版返工候选,交总工复审,不部署。
+
+## 实现
+
+沿用原人物、米色开衫、深灰长裤、卧室、机位与552px人物落点。现有 Wan2.2 原生首尾帧条件节点,使用原片 f48→f0,seed 712,实际生成一条49帧抬头回程。原49帧向前播放,随后49帧真实回程,98帧/16fps/6.125秒,内部接点48→49,跨周期97→0。没有倒放、冻结、静态摆动、遮脸淡化或短窗口裁切。
+
+原49帧 alpha 不重新分割;新增49帧补书本/衣袖正点,保留连通人物,去掉分离的沙发碎点。两段仅在原512px坐标 y175:330(输出552px坐标189:356)稳定蒙版:TV-L1对齐前后各2帧,有符号距离去掉最高/最低值,再取均值,限制到当前轮廓6px不确定区域,首尾8行回接原蒙版。只处理alpha,RGB不做时间平均;原发丝区域保持原alpha。原始源片与透明片逐帧映射见manifest/stabilization.json。
+
+第一次新增回程直接套原分割提示时,会漏掉书本/衣袖;该版已拒绝,`before-crops.png` 保留实证。补充分割提示后才进入最终蒙版稳定。共生成一条视频,没有批量换装或动作试片。第一次稳定使用中值共识,接头alpha变化仍偏集中;最终使用去极值均值,缓和了两个分段蒙版的多数票切换。
+
+## 同口径差异与边缘统计
+
+本报告独立固定口径:RGB头部框为512px源坐标 x230:340、y50:180;蒙版缩到512px、alpha>127,边界用二值侵蚀差,书本/手部带为全部x、y175:330。与HEL-710采用的框/缩放细节不同,所以这里原接头22.30不直接与审核卡29.5混算;下列前后全部由同一脚本重算。完整逐对数据见 `evidence/metrics.json`,覆盖全部98个相邻对(包括末→首)。
+
+| 头部RGB绝对差 /255 | 最小 | 中位 | P95 | 最大 |
+|---|---:|---:|---:|---:|
+| 原49帧普通相邻 | 0.36 | 1.73 | 4.97 | 5.75 |
+| 新98帧普通相邻(排除两接头) | 0.24 | 1.65 | 5.47 | 8.30 |
+
+原f48→f0:22.30;新内部f48→49:3.66;新跨周期f97→0:5.22。两个接头落在普通相邻变化范围内。接头前后整帧、头部放大与98帧序列均附;连续录屏为实际浏览器播放,未用离线合成冒充。
+
+| 蒙版指标 | 稳定前 | 最终稳定后 |
+|---|---:|---:|
+| 原49帧带内边界像素范围 | 510–794 | 509–604 |
+| 原49帧最差相邻边界变化 | 255 | 75 |
+| 原49帧相邻alpha开关最大像素 | 771 | 300 |
+| 全98帧边界像素范围 | 504–794 | 503–604 |
+| 全98对最差边界变化(含循环) | 261 | 75 |
+| 全98对alpha开关P95 / 最大像素 | 505.05 / 988 | 142.55 / 300 |
+| 内部 / 跨周期接头alpha开关像素 | 988 / 528 | 195 / 193 |
+| 动作补偿后alpha开关P95 / 最大 | 503.25 / 988 | 152.75 / 300 |
+
+动作补偿使用同一源RGB光流对齐前后蒙版;可见减少的是分割开关,不靠抹掉真实运动。人物内部透明片与源RGB编码误差逐帧中位1.89、最大2.10/255;无RGB时间混合。带外alpha逐数组一致为true。重点帧可见书本/指尖与袖口保持,未见新增吞手或RGB拖影;仍需总工以连续视频独立确认。没有把数值降低当作人体解剖或身份认证。
+
+## 验证
+
+- 来源包SHA256:60/60核对一致。
+- `npm ci --ignore-scripts --no-audit --no-fund`:沿用锁文件,无新依赖。
+- `npm run build`:严格TypeScript检查和Vite构建通过;原JS/CSS逻辑保持,返工通过素材和工具落地。
+- `ruff check src tests samples/bedroom/tools`:通过。
+- `PYTHONPATH=src python3 -m pytest -q`:78通过。
+- `LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/browser_check.py`:真实Chromium通过。最终视频先完整播放3周期,记录循环时间约6.28、12.42、18.56秒;此段全部图层显示,之后再执行交互测试。
+- VP9 alpha背景0/人物255;环境切换currentTime 0.905816→0.905816,source不变;图层开关、静态近景进退且播放继续、手动暂停、合成visibility暂停/恢复、减少动效切静态、缺片回退通过;1600×900、1280×800、390×844无横溢,人脸在画面内;pageerror=0。
+- 真实后台/锁屏、Safari/iOS未测;近景仅静态,未增加口型、语音或后台生活功能。手机构图、沙发几何、重打光与近景润色保持原记录。
+
+## 文件与运行边界
+
+修改 `tools/extract_layers.py`(可选补充提示)、新增 `tools/generate_return.py`、`stabilize_loop.py`、`measure_loop.py`,扩展 `tools/browser_check.py` 的连续三周期验证,更新manifest/README/本报告。没有改CSS、主题、核心服务或部署配置。
+
+分支继续 `hel-711-layered-bedroom`,基于原444ee56;最终提交与PR head核对写入附件 `DELIVERY.json` 及任务完成评论。素材不进Git。完整包包含源码、构建产物、manifest、SHA256SUMS、原始前程/回程、整周期源片、透明片、逐帧前后alpha、生成请求/节点/history/server日志、浏览器连续录屏、三视口及接头/手部证据、复现说明。
+
+严格校验了.12已确认SSH指纹。临时ComfyUI仅回环18189、精确PID600329,生成请求成功后已停止。CPU蒙版任务均前台收齐,没有遗留生成任务、没有新增依赖/模型/付费接口;ASR/TTS及18080原演示保持运行。未推main、未合并、未部署。
diff --git a/samples/bedroom/index.html b/samples/bedroom/index.html
new file mode 100644
index 0000000..70a653b
--- /dev/null
+++ b/samples/bedroom/index.html
@@ -0,0 +1,38 @@
+
+
+
卧室分层校对 · HEL-711
+
+
+
+
+
+
+
+
+
diff --git a/samples/bedroom/package-lock.json b/samples/bedroom/package-lock.json
new file mode 100644
index 0000000..7d2179a
--- /dev/null
+++ b/samples/bedroom/package-lock.json
@@ -0,0 +1,1166 @@
+{
+ "name": "cybergril-rain-room",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cybergril-rain-room",
+ "version": "0.1.0",
+ "dependencies": {
+ "pixi.js": "^8.0.0"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3",
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@pixi/colord": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
+ "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz",
+ "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz",
+ "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz",
+ "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz",
+ "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz",
+ "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz",
+ "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz",
+ "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz",
+ "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz",
+ "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz",
+ "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz",
+ "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz",
+ "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz",
+ "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz",
+ "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz",
+ "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz",
+ "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz",
+ "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz",
+ "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz",
+ "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz",
+ "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz",
+ "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz",
+ "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz",
+ "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz",
+ "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz",
+ "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/earcut": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
+ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@webgpu/types": {
+ "version": "0.1.74",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/earcut": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
+ "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gifuct-js": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
+ "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
+ "license": "MIT",
+ "dependencies": {
+ "js-binary-schema-parser": "^2.0.3"
+ }
+ },
+ "node_modules/ismobilejs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
+ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
+ "license": "MIT"
+ },
+ "node_modules/js-binary-schema-parser": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
+ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/parse-svg-path": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
+ "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pixi.js": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.22.0.tgz",
+ "integrity": "sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==",
+ "license": "MIT",
+ "workspaces": [
+ "examples",
+ "playground"
+ ],
+ "dependencies": {
+ "@pixi/colord": "^2.9.6",
+ "@types/earcut": "^3.0.0",
+ "@webgpu/types": "^0.1.69",
+ "@xmldom/xmldom": "^0.8.15",
+ "earcut": "^3.0.2",
+ "eventemitter3": "^5.0.1",
+ "gifuct-js": "^2.1.2",
+ "ismobilejs": "^1.1.1",
+ "parse-svg-path": "^0.2.0",
+ "tiny-lru": "^11.4.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/pixijs"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz",
+ "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.10",
+ "@rollup/rollup-android-arm64": "4.64.10",
+ "@rollup/rollup-darwin-arm64": "4.64.10",
+ "@rollup/rollup-darwin-x64": "4.64.10",
+ "@rollup/rollup-freebsd-arm64": "4.64.10",
+ "@rollup/rollup-freebsd-x64": "4.64.10",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.10",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.10",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.10",
+ "@rollup/rollup-linux-arm64-musl": "4.64.10",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.10",
+ "@rollup/rollup-linux-loong64-musl": "4.64.10",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.10",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.10",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.10",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.10",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.10",
+ "@rollup/rollup-linux-x64-gnu": "4.64.10",
+ "@rollup/rollup-linux-x64-musl": "4.64.10",
+ "@rollup/rollup-openbsd-x64": "4.64.10",
+ "@rollup/rollup-openharmony-arm64": "4.64.10",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.10",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.10",
+ "@rollup/rollup-win32-x64-gnu": "4.64.10",
+ "@rollup/rollup-win32-x64-msvc": "4.64.10",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tiny-lru": {
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/samples/bedroom/package.json b/samples/bedroom/package.json
new file mode 100644
index 0000000..b5317e8
--- /dev/null
+++ b/samples/bedroom/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "cybergril-rain-room",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview --host 127.0.0.1"
+ },
+ "dependencies": {
+ "pixi.js": "^8.0.0"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/samples/bedroom/public/assets/manifest.json b/samples/bedroom/public/assets/manifest.json
new file mode 100644
index 0000000..b4ad4df
--- /dev/null
+++ b/samples/bedroom/public/assets/manifest.json
@@ -0,0 +1,162 @@
+{
+ "schema": "hel712-review-1",
+ "status": "review_candidate",
+ "reference_sha256": {
+ "reference.png": "226d7922e042e22e4b8a8241a7cf6f7aea50c301029978e4ca26cad9b3d56359",
+ "environment.png": "b5d4771eb010dc1d0a41d1b698e45edaa45d2f90bb441378bc32cf5c812e76a2",
+ "talk-reference.png": "0ae9b1a1cb9c590ab89a81ce4efd25de843a600ff227c184663db23e9e9c16b3"
+ },
+ "camera": {
+ "canvas": [
+ 1672,
+ 941
+ ],
+ "generation_crop": [
+ 1120,
+ 200,
+ 552,
+ 552
+ ],
+ "person_anchor": [
+ 1390,
+ 530
+ ],
+ "coordinate_units": "source pixels, top left origin",
+ "transform": "none; crop reinserted into original position",
+ "clothing": "v2 cream cardigan, charcoal trousers"
+ },
+ "layers": [
+ {
+ "file": "environment.png",
+ "source": "approved 02-v2 original",
+ "alpha": false
+ },
+ {
+ "file": "contact-shadow.png",
+ "source": "engineered ellipse 65/255, 12px blur, not physically calibrated",
+ "alpha": true
+ },
+ {
+ "file": "person.png",
+ "source": "original 03-v2 RGB + existing SAM vit-b alpha; enclosed mask holes filled",
+ "alpha": true
+ },
+ {
+ "file": "occlusion.png",
+ "source": "03-v2 blanket pixels, hand-authored polygon matte",
+ "alpha": true
+ }
+ ],
+ "motion": {
+ "status": "真实回程与书本手部蒙版返工候选,待复审",
+ "file": "reading-alpha.webm",
+ "alpha": true,
+ "maskFile": null,
+ "original": "loop-source.webm",
+ "generator": "installed ComfyUI native Wan2.2 high/low 14B fp8 + installed LightX2V 4-step LoRAs",
+ "seed": 711,
+ "dimensions": [
+ 552,
+ 552
+ ],
+ "generation_dimensions": [
+ 512,
+ 512
+ ],
+ "frames": 98,
+ "fps": 16,
+ "duration_seconds": 6.125,
+ "codec": "VP9 yuva420p / WebM",
+ "matting": "original 49 alpha retained outside hand/book band; added return frames use book/sleeve foreground points + connected subject; y175:330 (512 source) uses TV-L1 aligned 5-frame trimmed signed-distance mean within 6px uncertainty band",
+ "start_pose": "seated holding open book, head slightly tilted toward camera",
+ "end_pose": "generated return to the original relaxed camera-facing pose",
+ "loop": "forward 0–48 + real generated return 49–97; joins 48→49 and 97→0, no reverse/fade/freeze",
+ "cut_point": "no subwindow trimming",
+ "return_seed": 712,
+ "source_map": [
+ {
+ "frames": [
+ 0,
+ 48
+ ],
+ "file": "reading_00001_.webm",
+ "source_frames": [
+ 0,
+ 48
+ ]
+ },
+ {
+ "frames": [
+ 49,
+ 97
+ ],
+ "file": "return_00001_.webm",
+ "source_frames": [
+ 0,
+ 48
+ ]
+ }
+ ]
+ },
+ "closeup": {
+ "file": "talk-reference.png",
+ "type": "static_reference_only",
+ "identity_verified": false,
+ "lipsync": false,
+ "source": "approved v2 static direction reference"
+ },
+ "known_issues": [
+ "Original empty/occupied sofa and pillow geometry differs; residual contact geometry needs art review.",
+ "Book and fingers are small; hand anatomy/identity not certified.",
+ "Manual environment dimming changes background only; subject relighting not implemented.",
+ "Static near-shot is a generated reference; cross-shot face continuity not certified.",
+ "No real weather, speech, lipsync or life-state integration.",
+ "Loop joins and stabilized hand/book edges are a HEL-712 candidate pending independent re-review."
+ ],
+ "files": {
+ "talk-reference.png": {
+ "bytes": 2246098,
+ "sha256": "0ae9b1a1cb9c590ab89a81ce4efd25de843a600ff227c184663db23e9e9c16b3"
+ },
+ "reading-alpha.webm": {
+ "bytes": 1971817,
+ "sha256": "9b9b95fff6c047c68ea66d14eba1ca896a80107b7c5b531c326bfac8bdc90b76"
+ },
+ "person.png": {
+ "bytes": 2522355,
+ "sha256": "a904f4081069ba97019d3c679d8dd59102118560471f8d6337f7b29d60380f24"
+ },
+ "composite.png": {
+ "bytes": 2576324,
+ "sha256": "4354716558858d0e9ac9a4c4a025d77f4042a0e106f10fc37e500c7157e77456"
+ },
+ "person-mask.png": {
+ "bytes": 7264,
+ "sha256": "7569160b3c87ebf1d5bb66792ed6d2db6b41fde006fad0473e7db5337d18b0f7"
+ },
+ "environment.png": {
+ "bytes": 2442707,
+ "sha256": "b5d4771eb010dc1d0a41d1b698e45edaa45d2f90bb441378bc32cf5c812e76a2"
+ },
+ "occlusion-mask.png": {
+ "bytes": 4838,
+ "sha256": "dc22d1823a02df4fa5f3bf484f157c49b270f100185c0a35b3c31f28880dd3f0"
+ },
+ "generation-input.png": {
+ "bytes": 508812,
+ "sha256": "f3922903fb28c5940a2e272b70be6e33dc094039b4e275724cf3ff1aa4af1584"
+ },
+ "occlusion.png": {
+ "bytes": 2522991,
+ "sha256": "5f0f4aa7ae90ad863f694365eaa93d7d7558841ecca08c95d415731450226c4f"
+ },
+ "reference.png": {
+ "bytes": 2384917,
+ "sha256": "226d7922e042e22e4b8a8241a7cf6f7aea50c301029978e4ca26cad9b3d56359"
+ },
+ "contact-shadow.png": {
+ "bytes": 16144,
+ "sha256": "948f88c1298638158f9a21ffe107bf403106dcf6e58c72a3d6e738fe0c7cc003"
+ }
+ }
+}
diff --git a/samples/bedroom/src/main.ts b/samples/bedroom/src/main.ts
new file mode 100644
index 0000000..e3990e7
--- /dev/null
+++ b/samples/bedroom/src/main.ts
@@ -0,0 +1,141 @@
+import './style.css';
+
+type Manifest = {
+ motion: { status: string; file: string | null; alpha: boolean; maskFile: string | null };
+};
+const get = (id: string) => document.getElementById(id) as T;
+const video = get('source');
+const canvas = get('motion');
+const context = canvas.getContext('2d')!;
+const play = get('play');
+const person = get('person');
+const status = get('status');
+const reduced = matchMedia('(prefers-reduced-motion: reduce)');
+const missingImages = new Set();
+let ready = false;
+let wantsPlay = false;
+let near = false;
+let failed = false;
+let frameHandle = 0;
+
+function fallback(message: string) {
+ const node = get('fallback');
+ node.hidden = false;
+ node.textContent = message;
+}
+
+for (const image of document.querySelectorAll('#frame img')) {
+ image.addEventListener('error', () => {
+ missingImages.add(image.id);
+ image.hidden = true;
+ fallback(`素材缺失:${image.id}。请解压任务素材包到 public/assets;当前内容不能作为验收结果。`);
+ });
+}
+
+function visibility() {
+ const visible = get('show-person').checked;
+ const maskOnly = get('show-mask').checked;
+ const activeMotion = ready && !failed && !reduced.matches;
+ person.hidden = !visible || maskOnly || near || activeMotion;
+ canvas.hidden = !visible || maskOnly || near || !activeMotion;
+ get('environment').hidden = maskOnly;
+ get('mask').hidden = !maskOnly || near;
+ get('shadow').hidden = !visible || maskOnly || near || !get('show-shadow').checked;
+ get('occlusion').hidden = !visible || maskOnly || near || !get('show-occlusion').checked;
+ get('talk').hidden = !near;
+ for (const id of missingImages) get(id).hidden = true;
+}
+
+function paint() {
+ if (video.readyState >= 2) {
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ context.globalCompositeOperation = 'source-over';
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+ }
+ get('time').textContent = ready
+ ? `动作位置:${video.currentTime.toFixed(2)} / ${Number.isFinite(video.duration) ? video.duration.toFixed(2) : '…'} 秒`
+ : '动作位置:未载入';
+}
+
+function draw() {
+ paint();
+ if (!video.paused && !document.hidden) frameHandle = requestAnimationFrame(draw);
+}
+
+async function syncPlayback() {
+ cancelAnimationFrame(frameHandle);
+ const run = wantsPlay && ready && !failed && !document.hidden && !reduced.matches;
+ if (run) {
+ try {
+ await video.play();
+ draw();
+ } catch {
+ wantsPlay = false;
+ video.pause();
+ fallback('浏览器未允许播放;请点击播放重试。');
+ }
+ } else { video.pause(); paint(); }
+ play.textContent = ready ? (video.paused ? '播放真实试片' : '暂停真实试片') : '真实动作尚不可用';
+ visibility();
+}
+
+function layout() {
+ const stage = get('frame');
+ const scale = Math.min(stage.clientWidth / 1672, stage.clientHeight / 941);
+ const left = (stage.clientWidth - 1672 * scale) / 2;
+ const top = (stage.clientHeight - 941 * scale) / 2;
+ Object.assign(canvas.style, { left: `${left + 1120 * scale}px`, top: `${top + 200 * scale}px`,
+ width: `${552 * scale}px`, height: `${552 * scale}px` });
+}
+new ResizeObserver(layout).observe(get('frame'));
+for (const input of document.querySelectorAll('input')) input.addEventListener('change', visibility);
+get('lighting').addEventListener('click', () => {
+ const button = get('lighting');
+ const dim = button.getAttribute('aria-pressed') !== 'true';
+ button.setAttribute('aria-pressed', String(dim));
+ button.textContent = `光照演示:${dim ? '环境减光' : '原始'}`;
+ get('environment').style.setProperty('--demo-environment-brightness', dim ? '0.65' : '1');
+ // No source assignment, load(), currentTime mutation or player recreation here.
+});
+get('closeup').addEventListener('click', () => {
+ near = !near;
+ const button = get('closeup');
+ button.setAttribute('aria-pressed', String(near));
+ button.textContent = near ? '退出静态近景' : '进入静态近景';
+ visibility();
+});
+play.addEventListener('click', () => { wantsPlay = !wantsPlay; void syncPlayback(); });
+document.addEventListener('visibilitychange', () => { void syncPlayback(); });
+reduced.addEventListener('change', () => {
+ if (reduced.matches) wantsPlay = false;
+ void syncPlayback();
+});
+video.addEventListener('error', () => {
+ failed = true; ready = false; wantsPlay = false; play.disabled = true;
+ fallback('动作素材缺失或解码失败,回退静态人物;未通过动态验证。');
+ void syncPlayback();
+});
+
+async function init() {
+ try {
+ const response = await fetch('./assets/manifest.json');
+ if (!response.ok) throw new Error('manifest');
+ const manifest: Manifest = await response.json();
+ const motion = manifest.motion;
+ if (!motion.file || !motion.alpha) {
+ status.textContent = '静态分层可校对;无合格的透明动作素材,动态验收未通过。';
+ play.disabled = true; play.textContent = '真实动作尚不可用'; return;
+ }
+ video.src = `./assets/${motion.file}`;
+ video.addEventListener('loadeddata', () => {
+ ready = true; play.disabled = false;
+ status.textContent = `真实生成试片 · ${motion.status} · 循环切点与身份一致性以检查报告为准`;
+ paint(); void syncPlayback();
+ }, { once: true });
+ } catch {
+ fallback('素材清单不可用;保留静态分层校对。');
+ play.textContent = '真实动作尚不可用';
+ }
+}
+visibility(); layout(); void init();
diff --git a/samples/bedroom/src/style.css b/samples/bedroom/src/style.css
new file mode 100644
index 0000000..103b958
--- /dev/null
+++ b/samples/bedroom/src/style.css
@@ -0,0 +1,22 @@
+@import './tokens.css';
+* { box-sizing: border-box; }
+[hidden] { display: none !important; }
+body { margin: 0; background: var(--scene-night-deep); color: var(--ink-primary); font: var(--fs-body) var(--font-sans); }
+main { max-width: 1600px; margin: auto; }
+header, section { padding: var(--space-4) var(--space-6); }
+h1 { font-size: var(--fs-title); margin: 0; }
+p { color: var(--ink-secondary); line-height: 1.5; }
+#stage { position: relative; overflow: hidden; height: min(60vh, 640px); background: var(--glass-bg-strong); }
+#frame { position: absolute; width: 100%; height: 100%; }
+#environment, .layer { position: absolute; width: 100%; height: 100%; object-fit: contain; }
+#environment { filter: brightness(var(--demo-environment-brightness, 1)); }
+#motion { position: absolute; }
+fieldset { border: 1px solid var(--glass-border); border-radius: var(--r-control); padding: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-4); }
+label, button { cursor: pointer; }
+.controls { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
+button { color: var(--ink-primary); background: var(--glass-bg-strong); border: 1px solid var(--glass-highlight); border-radius: var(--r-control); padding: var(--space-3) var(--space-4); font: inherit; }
+button[aria-pressed=true] { border-color: var(--accent); }
+button:disabled { color: var(--ink-disabled); cursor: default; }
+button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+output { font-variant-numeric: tabular-nums; color: var(--accent); }
+@media (max-width: 600px) { header, section { padding: var(--space-3); } #stage { height: 38vh; } }
diff --git a/samples/bedroom/src/tokens.css b/samples/bedroom/src/tokens.css
new file mode 100644
index 0000000..e42ddd6
--- /dev/null
+++ b/samples/bedroom/src/tokens.css
@@ -0,0 +1,73 @@
+/* Reused verbatim from hel-700-rain-room; no alternative product theme. */
+:root {
+ --scene-night-deep:#16233c;
+ --scene-city-blue:#305481;
+ --scene-city-mid:#5a76a2;
+ --scene-tower:#2b456b;
+ --scene-glass-blue:#718fc2;
+ --scene-amber:#e8a45c;
+ --scene-amber-soft:#f2c894;
+ --scene-wood:#59361d;
+ --scene-cream:#e8d9c8;
+ --scene-blanket:#362928;
+ --ink-primary:#f2efe9;
+ --ink-secondary:rgba(242,239,233,.64);
+ --ink-tertiary:rgba(242,239,233,.4);
+ --ink-disabled:rgba(242,239,233,.28);
+ --accent:#e8a45c;
+ --accent-hover:#f2b879;
+ --accent-pressed:#c9853f;
+ --accent-ink:#1a1208;
+ --glass-bg:rgba(14,18,30,.55);
+ --glass-bg-strong:rgba(14,18,30,.72);
+ --glass-border:rgba(255,255,255,.08);
+ --glass-highlight:rgba(255,255,255,.14);
+ --bubble-user:rgba(120,144,180,.2);
+ --bubble-user-border:rgba(255,255,255,.07);
+ --bubble-her:rgba(255,255,255,.06);
+ --bubble-failed:rgba(200,90,80,.14);
+ --state-danger:#e07a6a;
+ --font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Source Han Sans SC",system-ui,-apple-system,sans-serif;
+ --fs-caption:12px;
+ --fs-small:13px;
+ --fs-body:14px;
+ --fs-chat:15px;
+ --fs-nav:15px;
+ --fs-title:20px;
+ --fs-brand:16px;
+ --fs-time:13px;
+ --space-1:4px;
+ --space-2:8px;
+ --space-3:12px;
+ --space-4:16px;
+ --space-5:20px;
+ --space-6:24px;
+ --space-8:32px;
+ --space-10:40px;
+ --r-panel:24px;
+ --r-bubble:16px;
+ --r-card:16px;
+ --r-control:12px;
+ --r-pill:999px;
+ --shadow-panel:0 24px 80px rgba(0,0,0,.45);
+ --shadow-pop:0 8px 24px rgba(0,0,0,.35);
+ --glow-accent:0 0 12px rgba(232,164,92,.45);
+ --glass-blur:blur(28px) saturate(1.2);
+ --z-scene-far:0;
+ --z-scene-rain:1;
+ --z-scene-near:2;
+ --z-caption:10;
+ --z-nav:10;
+ --z-topbar:20;
+ --z-panel:30;
+ --z-toast:40;
+ --ease-out:cubic-bezier(.23,1,.32,1);
+ --ease-in-out:cubic-bezier(.77,0,.175,1);
+ --ease-drawer:cubic-bezier(.32,.72,0,1);
+ --lamp-opacity:.12;
+ --panel-width:clamp(360px,30vw,460px);
+ --panel-top:64px;
+ --panel-inset:24px;
+ --scene-height:330px;
+ --drawer-height:30dvh;
+}
\ No newline at end of file
diff --git a/samples/bedroom/tools/browser_check.py b/samples/bedroom/tools/browser_check.py
new file mode 100644
index 0000000..3b6aef9
--- /dev/null
+++ b/samples/bedroom/tools/browser_check.py
@@ -0,0 +1,158 @@
+"""Foreground Chromium interaction checks, screenshots and downloadable recording."""
+import functools
+import http.server
+import json
+import threading
+from pathlib import Path
+
+from playwright.sync_api import sync_playwright
+
+ROOT = Path(__file__).resolve().parents[1]
+EVIDENCE = ROOT / 'evidence'
+
+
+class Quiet(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+
+def main():
+ EVIDENCE.mkdir(exist_ok=True)
+ handler = functools.partial(Quiet, directory=str(ROOT / 'dist'))
+ server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler)
+ thread = threading.Thread(target=server.serve_forever)
+ thread.start()
+ results = {}
+ try:
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ url = f'http://127.0.0.1:{server.server_port}'
+ context = browser.new_context(viewport={'width': 1280, 'height': 800},
+ record_video_dir=str(EVIDENCE),
+ record_video_size={'width': 1280, 'height': 800})
+ page = context.new_page()
+ errors = []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ page.goto(url)
+ page.wait_for_function('!document.querySelector("#play").disabled')
+ assert page.locator('#source').evaluate('(v) => v.paused')
+ page.click('#play')
+ # Keep every layer visible for three full cycles before interaction checks.
+ duration = page.locator('#source').evaluate('(v) => v.duration')
+ page.evaluate("""() => {
+ window.observedLoops = []; window.lastVideoTime = 0;
+ const v = document.querySelector('#source');
+ v.addEventListener('timeupdate', () => {
+ if (v.currentTime < window.lastVideoTime) window.observedLoops.push(performance.now());
+ window.lastVideoTime = v.currentTime;
+ });
+ }""")
+ page.wait_for_timeout((duration * 3 + 0.8) * 1000)
+ observed = page.evaluate('window.observedLoops')
+ assert len(observed) >= 3, observed
+ results['continuous_three_cycles'] = {'duration': duration, 'wraps_ms': observed}
+ page.wait_for_timeout(800)
+ alpha = page.locator('#motion').evaluate('(c) => {const x=c.getContext("2d"); return [x.getImageData(0,0,1,1).data[3], x.getImageData(300,220,1,1).data[3]]}')
+ assert alpha[0] == 0 and alpha[1] > 240, alpha
+ results['vp9_alpha_decoded_by_chromium'] = alpha
+ page.wait_for_function('document.querySelector("#source").currentTime > 0.9 && document.querySelector("#source").currentTime < 1.4')
+ positions = page.evaluate('''() => {
+ const video=document.querySelector('#source'); const before=video.currentTime;
+ const source=video.currentSrc; document.querySelector('#lighting').click();
+ return {before, after:video.currentTime, sourcePreserved: source===video.currentSrc};
+ }''')
+ assert positions['before'] == positions['after'] and positions['sourcePreserved']
+ results['environment_toggle_no_seek'] = positions
+ page.wait_for_timeout(650)
+ assert page.locator('#source').evaluate('(v) => !v.paused')
+ page.click('#show-person')
+ assert page.locator('#motion').is_hidden() and page.locator('#person').is_hidden()
+ page.wait_for_timeout(600)
+ page.screenshot(path=str(EVIDENCE / 'environment-only.png'))
+ page.click('#show-person')
+ page.click('#show-occlusion')
+ assert page.locator('#occlusion').is_hidden()
+ page.click('#show-occlusion')
+ page.click('#show-shadow')
+ assert page.locator('#shadow').is_hidden()
+ page.click('#show-shadow')
+ page.click('#show-mask')
+ assert page.locator('#mask').is_visible() and page.locator('#environment').is_hidden()
+ page.wait_for_timeout(500)
+ page.click('#show-mask')
+ results['independent_layers'] = True
+ page.click('#closeup')
+ assert page.locator('#talk').is_visible()
+ assert page.locator('#source').evaluate('(v) => !v.paused')
+ page.wait_for_timeout(700)
+ page.screenshot(path=str(EVIDENCE / 'static-near.png'))
+ page.click('#closeup')
+ assert page.locator('#talk').is_hidden()
+ page.wait_for_timeout(6400) # Includes the forward and generated return.
+ results['near_is_static_player_continues'] = True
+ page.click('#play')
+ t = page.locator('#source').evaluate('(v) => v.currentTime')
+ page.wait_for_timeout(250)
+ assert page.locator('#source').evaluate('(v) => v.currentTime') == t
+ results['manual_pause'] = True
+ # Chromium lifecycle API freezes the page after making it hidden.
+ session = context.new_cdp_session(page)
+ session.send('Page.setWebLifecycleState', {'state': 'active'})
+ # Visibility handler unit probe. Browser screenshot/recording checks above are real.
+ page.click('#play')
+ page.evaluate('''() => {
+ Object.defineProperty(document, 'hidden', {configurable:true, get:()=>true});
+ document.dispatchEvent(new Event('visibilitychange'));
+ }''')
+ assert page.locator('#source').evaluate('(v) => v.paused')
+ page.evaluate('''() => {
+ delete document.hidden; document.dispatchEvent(new Event('visibilitychange'));
+ }''')
+ page.wait_for_function('!document.querySelector("#source").paused')
+ results['visibility_pause_resume_handler_synthetic_event'] = True
+ for width, height in [(1600, 900), (1280, 800), (390, 844)]:
+ page.set_viewport_size({'width': width, 'height': height})
+ page.wait_for_timeout(180)
+ bounds = page.evaluate('''() => {
+ const r=document.querySelector('#motion').getBoundingClientRect();
+ const s=document.querySelector('#stage').getBoundingClientRect();
+ return {faceX:r.x+305/552*r.width, faceY:r.y+135/552*r.height,
+ stage:{left:s.left,right:s.right,top:s.top,bottom:s.bottom},
+ scroll:document.documentElement.scrollWidth, width:innerWidth};
+ }''')
+ assert bounds['scroll'] <= width
+ assert bounds['stage']['left'] <= bounds['faceX'] <= bounds['stage']['right']
+ assert bounds['stage']['top'] <= bounds['faceY'] <= bounds['stage']['bottom']
+ page.screenshot(path=str(EVIDENCE / f'viewport-{width}x{height}.png'))
+ results[f'viewport_{width}x{height}'] = bounds
+ page.emulate_media(reduced_motion='reduce')
+ page.wait_for_timeout(100)
+ assert page.locator('#source').evaluate('(v) => v.paused')
+ assert page.locator('#person').is_visible() and page.locator('#motion').is_hidden()
+ results['reduced_motion_static_fallback'] = True
+ page.emulate_media(reduced_motion='no-preference')
+ assert page.locator('#source').evaluate('(v) => v.paused')
+ video_path = Path(page.video.path())
+ context.close()
+ video_path.rename(EVIDENCE / 'browser-continuous.webm')
+ missing = browser.new_page()
+ missing.route('**/reading-alpha.webm', lambda route: route.abort())
+ missing.goto(url)
+ missing.wait_for_function('!document.querySelector("#fallback").hidden')
+ assert missing.locator('#person').is_visible()
+ assert missing.locator('#play').is_disabled()
+ results['missing_motion_static_fallback'] = True
+ missing.close()
+ assert not errors, errors
+ results['page_errors'] = errors
+ browser.close()
+ finally:
+ server.shutdown()
+ thread.join()
+ server.server_close()
+ (EVIDENCE / 'browser-results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2))
+ print(json.dumps(results, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/contact_layers.py b/samples/bedroom/tools/contact_layers.py
new file mode 100644
index 0000000..f866b05
--- /dev/null
+++ b/samples/bedroom/tools/contact_layers.py
@@ -0,0 +1,33 @@
+"""Reproduce the separately switchable foreground and provisional contact shadow."""
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFilter
+
+
+def main():
+ assets = Path(__file__).resolve().parents[1] / 'public/assets'
+ source = Image.open(assets / 'reference.png').convert('RGBA')
+ mask = Image.new('L', source.size)
+ ImageDraw.Draw(mask).polygon([
+ (1240, 582), (1282, 566), (1316, 556), (1360, 552), (1403, 532),
+ (1438, 526), (1486, 514), (1517, 521), (1539, 544), (1559, 556),
+ (1565, 634), (1520, 707), (1407, 747), (1260, 703), (1227, 650),
+ ], fill=255)
+ mask = mask.filter(ImageFilter.GaussianBlur(1))
+ source.putalpha(mask)
+ source.save(assets / 'occlusion.png')
+ mask.save(assets / 'occlusion-mask.png')
+ mask = Image.new('L', source.size)
+ ImageDraw.Draw(mask).ellipse((1230, 511, 1512, 595), fill=65)
+ mask = mask.filter(ImageFilter.GaussianBlur(12))
+ shadow = Image.new('RGBA', source.size, (44, 28, 20, 0))
+ shadow.putalpha(mask)
+ shadow.save(assets / 'contact-shadow.png')
+ composite = Image.open(assets / 'environment.png').convert('RGBA')
+ for name in ['contact-shadow.png', 'person.png', 'occlusion.png']:
+ composite = Image.alpha_composite(composite, Image.open(assets / name))
+ composite.save(assets / 'composite.png')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/extract_layers.py b/samples/bedroom/tools/extract_layers.py
new file mode 100644
index 0000000..e530049
--- /dev/null
+++ b/samples/bedroom/tools/extract_layers.py
@@ -0,0 +1,79 @@
+"""Use installed SAM modeling modules directly; no predictor/OpenCV dependency.
+
+Run in the existing ComfyUI venv. No downloads or dependency changes.
+"""
+import argparse
+import importlib
+import sys
+import types
+from pathlib import Path
+
+import numpy as np
+import torch
+from PIL import Image, ImageChops, ImageDraw, ImageFilter
+
+
+def load_sam(source: Path, checkpoint: Path):
+ # The optional predictor package imports cv2. We only use the original model API.
+ package = types.ModuleType("hel711_sam")
+ package.__path__ = [str(source)]
+ sys.modules[package.__name__] = package
+ builder = importlib.import_module("hel711_sam.build_sam")
+ return builder.build_sam_vit_b(str(checkpoint)).eval()
+
+
+def segment(model, image, extra_positive=()):
+ width, height = image.size
+ scale = 1024 / max(width, height)
+ size = (round(width * scale), round(height * scale))
+ tensor = torch.from_numpy(np.array(image.resize(size))).permute(2, 0, 1).to(model.device)
+ # Crop coordinate system: fixed right-hand seat, 552 x 552.
+ points = torch.tensor([[[305, 140], [300, 245], [200, 300], [307, 301],
+ [80, 100], [450, 400], [330, 450]]], dtype=torch.float)
+ if extra_positive:
+ extra = torch.tensor([extra_positive], dtype=torch.float)
+ points = torch.cat((points, extra), dim=1)
+ points = (points * scale).to(model.device)
+ labels = torch.tensor([[1, 1, 1, 1, 0, 0, 0]], device=model.device)
+ if extra_positive:
+ labels = torch.cat((labels, torch.ones((1, len(extra_positive)), device=model.device)), dim=1)
+ box = torch.tensor([[100, 60, 420, 380]], dtype=torch.float, device=model.device) * scale
+ with torch.inference_mode():
+ result = model([dict(image=tensor, original_size=(height, width),
+ point_coords=points, point_labels=labels, boxes=box)],
+ multimask_output=False)[0]
+ mask = result['masks'][0, 0].cpu().numpy().astype(np.uint8) * 255
+ mask = Image.fromarray(mask)
+ # The book/knit texture produces enclosed SAM holes. Fill enclosed holes only.
+ exterior = mask.copy()
+ ImageDraw.floodfill(exterior, (0, 0), 255)
+ mask = ImageChops.lighter(mask, ImageChops.invert(exterior))
+ return mask.filter(ImageFilter.GaussianBlur(0.65))
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('assets', type=Path)
+ parser.add_argument('--sam-source', type=Path, required=True)
+ parser.add_argument('--checkpoint', type=Path, required=True)
+ args = parser.parse_args()
+ torch.set_num_threads(8)
+ model = load_sam(args.sam_source, args.checkpoint)
+ source = Image.open(args.assets / 'reference.png').convert('RGB')
+ region = (1120, 200, 1672, 752)
+ crop = source.crop(region)
+ mask = segment(model, crop)
+ full_mask = Image.new('L', source.size)
+ full_mask.paste(mask, region[:2])
+ full_mask.save(args.assets / 'person-mask.png')
+ layer = source.convert('RGBA')
+ layer.putalpha(full_mask)
+ layer.save(args.assets / 'person.png')
+ crop.save(args.assets / 'generation-input.png')
+ environment = Image.open(args.assets / 'environment.png').convert('RGBA')
+ Image.alpha_composite(environment, layer).save(args.assets / 'composite.png')
+ print('Saved original-pixel person layer, SAM mask, crop and real composite.', flush=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/generate_once.py b/samples/bedroom/tools/generate_once.py
new file mode 100644
index 0000000..63cc64b
--- /dev/null
+++ b/samples/bedroom/tools/generate_once.py
@@ -0,0 +1,141 @@
+"""One foreground job using the installed ComfyUI native Wan nodes.
+
+Owns and stops its exact server PID in finally. Does not modify the installation.
+"""
+import json
+import os
+import subprocess
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path('/media/lucian/Downloads/ComfyUI')
+TASK = ROOT / 'output/hel711-task'
+URL = 'http://127.0.0.1:18189'
+
+
+def request(path, payload=None):
+ data = None if payload is None else json.dumps(payload).encode()
+ req = urllib.request.Request(URL + path, data=data,
+ headers={'Content-Type': 'application/json'})
+ with urllib.request.urlopen(req, timeout=20) as response:
+ return json.load(response)
+
+
+def graph():
+ nodes = {}
+
+ def add(key, kind, **inputs):
+ nodes[str(key)] = dict(class_type=kind, inputs=inputs)
+
+ for key, mode in [(1, 'high'), (2, 'low')]:
+ add(key, 'UNETLoader', unet_name=f'wan2.2_i2v_{mode}_noise_14B_fp8_scaled.safetensors',
+ weight_dtype='default')
+ add(key + 2, 'LoraLoaderModelOnly', model=[str(key), 0],
+ lora_name=f'wan2.2_i2v_lightx2v_4steps_lora_v1_{mode}_noise.safetensors',
+ strength_model=1.0)
+ add(key + 4, 'ModelSamplingSD3', model=[str(key + 2), 0], shift=5.0)
+ add(7, 'CLIPLoader', clip_name='umt5_xxl_fp8_e4m3fn_scaled.safetensors',
+ type='wan', device='cpu')
+ add(8, 'VAELoader', vae_name='wan_2.1_vae.safetensors')
+ add(9, 'LoadImage', image='generation-input.png')
+ add(10, 'CLIPTextEncode', clip=['7', 0], text=(
+ 'Locked off camera, photorealistic young adult woman with short dark hair, '
+ 'cream cardigan and dark trousers seated on the same sofa in the same bedroom. '
+ 'She quietly reads the book in her hands, slowly lowers her gaze to the page '
+ 'then gently raises her head a little. Small natural eye blink and breathing. '
+ 'Her hands keep holding the book. Fixed seat position, stationary room and furniture.'))
+ add(11, 'CLIPTextEncode', clip=['7', 0],
+ text='camera movement, zoom, changing face, changing clothes, standing up, '
+ 'extra fingers, distorted hands, deformed face, moving furniture, subtitles')
+ add(12, 'WanImageToVideo', positive=['10', 0], negative=['11', 0], vae=['8', 0],
+ width=512, height=512, length=49, batch_size=1, start_image=['9', 0])
+ add(13, 'KSamplerAdvanced', model=['5', 0], add_noise='enable', noise_seed=711,
+ steps=4, cfg=1.0, sampler_name='euler', scheduler='simple',
+ positive=['12', 0], negative=['12', 1], latent_image=['12', 2],
+ start_at_step=0, end_at_step=2, return_with_leftover_noise='enable')
+ add(14, 'KSamplerAdvanced', model=['6', 0], add_noise='disable', noise_seed=711,
+ steps=4, cfg=1.0, sampler_name='euler', scheduler='simple',
+ positive=['12', 0], negative=['12', 1], latent_image=['13', 0],
+ start_at_step=2, end_at_step=4, return_with_leftover_noise='disable')
+ add(15, 'VAEDecode', samples=['14', 0], vae=['8', 0])
+ add(16, 'SaveWEBM', images=['15', 0], codec='vp9', fps=16.0,
+ filename_prefix='reading', crf=20.0)
+ return nodes
+
+
+def main():
+ for name in ['user', 'temp']:
+ (TASK / name).mkdir(exist_ok=True)
+ os.environ['HF_HUB_OFFLINE'] = '1'
+ os.environ['TRANSFORMERS_OFFLINE'] = '1'
+ command = [str(ROOT / 'venv/bin/python'), str(ROOT / 'main.py'),
+ '--listen', '127.0.0.1', '--port', '18189', '--disable-all-custom-nodes',
+ '--offline', '--disable-manager-ui', '--disable-assets',
+ '--reserve-vram', '8', '--disable-dynamic-vram',
+ '--input-directory', str(TASK / 'assets'),
+ '--output-directory', str(TASK), '--user-directory', str(TASK / 'user'),
+ '--temp-directory', str(TASK / 'temp'), '--database-url', 'sqlite:///:memory:']
+ # Refuse to attach to an unrelated listener.
+ import socket
+ with socket.socket() as sock:
+ if sock.connect_ex(('127.0.0.1', 18189)) == 0:
+ raise RuntimeError('Task port already occupied; no process started')
+ with open(TASK / 'server.log', 'w') as log:
+ server = subprocess.Popen(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT)
+ (TASK / 'process.json').write_text(json.dumps(dict(pid=server.pid, command=command)))
+ try:
+ deadline = time.monotonic() + 90
+ while True:
+ if server.poll() is not None:
+ raise RuntimeError(f'ComfyUI exited: {server.returncode}; see server.log')
+ try:
+ info = request('/object_info')
+ break
+ except (OSError, urllib.error.URLError):
+ if time.monotonic() > deadline:
+ raise TimeoutError('Startup exceeded 90 seconds') from None
+ time.sleep(1)
+ workflow = graph()
+ (TASK / 'workflow.json').write_text(json.dumps(workflow, indent=2))
+ required = {n['class_type']: info.get(n['class_type']) for n in workflow.values()}
+ (TASK / 'nodes.json').write_text(json.dumps(required, indent=2))
+ if any(v is None for v in required.values()):
+ raise RuntimeError('Required native node unavailable; see nodes.json')
+ result = request('/prompt', {'prompt': workflow})
+ (TASK / 'submission.json').write_text(json.dumps(result))
+ prompt_id = result['prompt_id']
+ print(f'Generating one clip: {prompt_id}; owned server PID {server.pid}', flush=True)
+ deadline = time.monotonic() + 2400
+ last_report = 0
+ while time.monotonic() < deadline:
+ history = request('/history/' + prompt_id)
+ if prompt_id in history:
+ (TASK / 'history.json').write_text(json.dumps(history, indent=2))
+ print(json.dumps(history[prompt_id]['status']), flush=True)
+ if history[prompt_id]['status']['status_str'] != 'success':
+ raise RuntimeError('Generation failed; see history.json')
+ break
+ if time.monotonic() - last_report > 30:
+ print('Single generation still active; existing services untouched.', flush=True)
+ last_report = time.monotonic()
+ time.sleep(3)
+ else:
+ request('/interrupt', {})
+ raise TimeoutError('Single generation exceeded 40 minutes')
+ except urllib.error.HTTPError as error:
+ (TASK / 'api-error.json').write_bytes(error.read())
+ raise
+ finally:
+ server.terminate()
+ try:
+ server.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ server.kill()
+ server.wait()
+ print(f'Stopped owned server PID {server.pid}', flush=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/generate_return.py b/samples/bedroom/tools/generate_return.py
new file mode 100644
index 0000000..cfc8fd8
--- /dev/null
+++ b/samples/bedroom/tools/generate_return.py
@@ -0,0 +1,149 @@
+"""Generate one conditioned return, from original frame 48 back to frame 0.
+
+Owns and stops its exact server PID in finally. Does not modify the installation.
+"""
+import json
+import os
+import subprocess
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path('/media/lucian/Downloads/ComfyUI')
+TASK = ROOT / 'output/hel712-task'
+URL = 'http://127.0.0.1:18189'
+
+
+def request(path, payload=None):
+ data = None if payload is None else json.dumps(payload).encode()
+ req = urllib.request.Request(URL + path, data=data,
+ headers={'Content-Type': 'application/json'})
+ with urllib.request.urlopen(req, timeout=20) as response:
+ return json.load(response)
+
+
+def graph():
+ nodes = {}
+
+ def add(key, kind, **inputs):
+ nodes[str(key)] = dict(class_type=kind, inputs=inputs)
+
+ for key, mode in [(1, 'high'), (2, 'low')]:
+ add(key, 'UNETLoader', unet_name=f'wan2.2_i2v_{mode}_noise_14B_fp8_scaled.safetensors',
+ weight_dtype='default')
+ add(key + 2, 'LoraLoaderModelOnly', model=[str(key), 0],
+ lora_name=f'wan2.2_i2v_lightx2v_4steps_lora_v1_{mode}_noise.safetensors',
+ strength_model=1.0)
+ add(key + 4, 'ModelSamplingSD3', model=[str(key + 2), 0], shift=5.0)
+ add(7, 'CLIPLoader', clip_name='umt5_xxl_fp8_e4m3fn_scaled.safetensors',
+ type='wan', device='cpu')
+ add(8, 'VAELoader', vae_name='wan_2.1_vae.safetensors')
+ add(9, 'LoadImage', image='return-start.png')
+ add(17, 'LoadImage', image='return-end.png')
+ add(10, 'CLIPTextEncode', clip=['7', 0], text=(
+ 'Locked off camera, photorealistic young adult woman with short dark hair, '
+ 'cream cardigan and dark trousers seated on the same sofa in the same bedroom. '
+ 'She slowly and smoothly raises her head from looking down at the book, '
+ 'returning to the exact relaxed head pose and eye direction in the final reference. '
+ 'Her hands keep holding the open book without moving it. Natural continuous breathing. '
+ 'Fixed seat position, stationary room and furniture, identical face and outfit.'))
+ add(11, 'CLIPTextEncode', clip=['7', 0],
+ text='camera movement, zoom, changing face, changing clothes, standing up, '
+ 'extra fingers, distorted hands, deformed face, moving furniture, subtitles')
+ add(12, 'WanFirstLastFrameToVideo', positive=['10', 0], negative=['11', 0], vae=['8', 0],
+ width=512, height=512, length=49, batch_size=1, start_image=['9', 0], end_image=['17', 0])
+ add(13, 'KSamplerAdvanced', model=['5', 0], add_noise='enable', noise_seed=712,
+ steps=4, cfg=1.0, sampler_name='euler', scheduler='simple',
+ positive=['12', 0], negative=['12', 1], latent_image=['12', 2],
+ start_at_step=0, end_at_step=2, return_with_leftover_noise='enable')
+ add(14, 'KSamplerAdvanced', model=['6', 0], add_noise='disable', noise_seed=712,
+ steps=4, cfg=1.0, sampler_name='euler', scheduler='simple',
+ positive=['12', 0], negative=['12', 1], latent_image=['13', 0],
+ start_at_step=2, end_at_step=4, return_with_leftover_noise='disable')
+ add(15, 'VAEDecode', samples=['14', 0], vae=['8', 0])
+ add(16, 'SaveWEBM', images=['15', 0], codec='vp9', fps=16.0,
+ filename_prefix='return', crf=20.0)
+ return nodes
+
+
+def main():
+ import av
+ (TASK / 'assets').mkdir(parents=True, exist_ok=True)
+ with av.open(str(ROOT / 'output/hel711-task/reading_00001_.webm')) as source:
+ frames = [frame.to_image() for frame in source.decode(video=0)]
+ frames[-1].save(TASK / 'assets/return-start.png')
+ frames[0].save(TASK / 'assets/return-end.png')
+ for name in ['user', 'temp']:
+ (TASK / name).mkdir(exist_ok=True)
+ os.environ['HF_HUB_OFFLINE'] = '1'
+ os.environ['TRANSFORMERS_OFFLINE'] = '1'
+ command = [str(ROOT / 'venv/bin/python'), str(ROOT / 'main.py'),
+ '--listen', '127.0.0.1', '--port', '18189', '--disable-all-custom-nodes',
+ '--offline', '--disable-manager-ui', '--disable-assets',
+ '--reserve-vram', '8', '--disable-dynamic-vram',
+ '--input-directory', str(TASK / 'assets'),
+ '--output-directory', str(TASK), '--user-directory', str(TASK / 'user'),
+ '--temp-directory', str(TASK / 'temp'), '--database-url', 'sqlite:///:memory:']
+ # Refuse to attach to an unrelated listener.
+ import socket
+ with socket.socket() as sock:
+ if sock.connect_ex(('127.0.0.1', 18189)) == 0:
+ raise RuntimeError('Task port already occupied; no process started')
+ with open(TASK / 'server.log', 'w') as log:
+ server = subprocess.Popen(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT)
+ (TASK / 'process.json').write_text(json.dumps(dict(pid=server.pid, command=command)))
+ try:
+ deadline = time.monotonic() + 90
+ while True:
+ if server.poll() is not None:
+ raise RuntimeError(f'ComfyUI exited: {server.returncode}; see server.log')
+ try:
+ info = request('/object_info')
+ break
+ except (OSError, urllib.error.URLError):
+ if time.monotonic() > deadline:
+ raise TimeoutError('Startup exceeded 90 seconds') from None
+ time.sleep(1)
+ workflow = graph()
+ (TASK / 'workflow.json').write_text(json.dumps(workflow, indent=2))
+ required = {n['class_type']: info.get(n['class_type']) for n in workflow.values()}
+ (TASK / 'nodes.json').write_text(json.dumps(required, indent=2))
+ if any(v is None for v in required.values()):
+ raise RuntimeError('Required native node unavailable; see nodes.json')
+ result = request('/prompt', {'prompt': workflow})
+ (TASK / 'submission.json').write_text(json.dumps(result))
+ prompt_id = result['prompt_id']
+ print(f'Generating one clip: {prompt_id}; owned server PID {server.pid}', flush=True)
+ deadline = time.monotonic() + 2400
+ last_report = 0
+ while time.monotonic() < deadline:
+ history = request('/history/' + prompt_id)
+ if prompt_id in history:
+ (TASK / 'history.json').write_text(json.dumps(history, indent=2))
+ print(json.dumps(history[prompt_id]['status']), flush=True)
+ if history[prompt_id]['status']['status_str'] != 'success':
+ raise RuntimeError('Generation failed; see history.json')
+ break
+ if time.monotonic() - last_report > 30:
+ print('Single generation still active; existing services untouched.', flush=True)
+ last_report = time.monotonic()
+ time.sleep(3)
+ else:
+ request('/interrupt', {})
+ raise TimeoutError('Single generation exceeded 40 minutes')
+ except urllib.error.HTTPError as error:
+ (TASK / 'api-error.json').write_bytes(error.read())
+ raise
+ finally:
+ server.terminate()
+ try:
+ server.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ server.kill()
+ server.wait()
+ print(f'Stopped owned server PID {server.pid}', flush=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/matte_video.py b/samples/bedroom/tools/matte_video.py
new file mode 100644
index 0000000..1654818
--- /dev/null
+++ b/samples/bedroom/tools/matte_video.py
@@ -0,0 +1,59 @@
+"""Per-frame real SAM segmentation, then VP9 alpha encoding with existing PyAV."""
+import json
+from pathlib import Path
+
+import av
+import numpy as np
+import torch
+from extract_layers import load_sam, segment
+from PIL import Image
+
+ROOT = Path('/media/lucian/Downloads/ComfyUI')
+TASK = ROOT / 'output/hel711-task'
+
+
+def main():
+ torch.set_num_threads(8)
+ model = load_sam(
+ ROOT / 'custom_nodes/was-node-suite-comfyui/repos/SAM/segment_anything',
+ ROOT / 'models/sams/sam_vit_b_01ec64.pth').to('cuda')
+ output = av.open(str(TASK / 'reading-alpha.webm'), 'w')
+ stream = output.add_stream('libvpx-vp9', rate=16)
+ stream.width = 552
+ stream.height = 552
+ stream.pix_fmt = 'yuva420p'
+ stream.options = {'crf': '20', 'b': '0', 'auto-alt-ref': '0'}
+ metrics = []
+ previews = []
+ source = av.open(str(TASK / 'reading_00001_.webm'))
+ previous = None
+ first = None
+ for index, decoded in enumerate(source.decode(video=0)):
+ image = decoded.to_image().resize((552, 552), Image.Resampling.LANCZOS)
+ mask = segment(model, image)
+ frame = image.convert('RGBA')
+ frame.putalpha(mask)
+ pixels = np.array(image).astype(float)
+ if first is None:
+ first = pixels
+ metrics.append(dict(frame=index, mask_pixels=int((np.array(mask) > 127).sum()),
+ mean_absolute_rgb_change=0 if previous is None else float(np.abs(pixels-previous).mean())))
+ previous = pixels
+ if index in [0, 16, 32, 48]:
+ previews.append(frame)
+ frame.save(TASK / f'matted-{index:02}.png')
+ for packet in stream.encode(av.VideoFrame.from_ndarray(np.array(frame), format='rgba')):
+ output.mux(packet)
+ if index % 16 == 0:
+ print(f'Matted frame {index}', flush=True)
+ for packet in stream.encode():
+ output.mux(packet)
+ output.close()
+ source.close()
+ (TASK / 'frame-metrics.json').write_text(json.dumps(dict(
+ frames=metrics, loop_rgb_difference=float(np.abs(previous-first).mean()),
+ interpretation='Real generated frames; RGB differences alone are not proof of valid anatomy or identity.'), indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tools/measure_loop.py b/samples/bedroom/tools/measure_loop.py
new file mode 100644
index 0000000..1c0c7a8
--- /dev/null
+++ b/samples/bedroom/tools/measure_loop.py
@@ -0,0 +1,106 @@
+"""Measure all joins and before/after hand mattes; export reviewable frame evidence."""
+import json
+from pathlib import Path
+
+import av
+import numpy as np
+from PIL import Image, ImageDraw
+from scipy.ndimage import binary_erosion, map_coordinates
+from skimage.color import rgb2gray
+from skimage.registration import optical_flow_tvl1
+
+TASK = Path('/media/lucian/Downloads/ComfyUI/output/hel712-task')
+
+
+def summary(values):
+ a = np.array(values)
+ return dict(min=float(a.min()), median=float(np.median(a)),
+ p95=float(np.percentile(a, 95)), max=float(a.max()))
+
+
+def panel(images, labels, path, size=(256, 256), columns=6):
+ width, height = size
+ board = Image.new('RGB', (columns*width, ((len(images)+columns-1)//columns)*(height+22)), '#202020')
+ draw = ImageDraw.Draw(board)
+ for i, (image, label) in enumerate(zip(images, labels, strict=True)):
+ x, y = i % columns*width, i // columns*(height+22)
+ board.paste(image.resize(size), (x, y))
+ draw.text((x+3, y+height+3), label, fill='white')
+ board.save(path)
+
+
+def main():
+ data = np.load(TASK/'segmentation-v2.npz')
+ mattes = np.load(TASK/'mattes.npz')
+ rgb = data['images']
+ # All measurement uses 512px source coordinates. The head box is fixed for both clips.
+ images = [np.array(Image.fromarray(x).resize((512,512))) for x in rgb]
+ before, after = [[np.array(Image.fromarray(x).resize((512,512))) > 127
+ for x in mattes[key]] for key in ['before','after']]
+ head = [x[50:180,230:340].astype(float) for x in images]
+ pairs = [(i, i+1) for i in range(97)] + [(97,0)]
+ head_diff = [float(np.abs(head[a]-head[b]).mean()) for a,b in pairs]
+ ordinary = [d for i,d in enumerate(head_diff) if i not in [48,97]]
+ report = {'coordinates':'512x512, head x230:340 y50:180, book/hand all x y175:330',
+ 'original_head_adjacent':summary(head_diff[:48]),
+ 'original_head_seam':float(np.abs(head[48]-head[0]).mean()),
+ 'loop_ordinary_head':summary(ordinary),
+ 'joins':[{'pair':[48,49],'head_mae':head_diff[48]},
+ {'pair':[97,0],'head_mae':head_diff[97]}],
+ 'head_mae_all_pairs':head_diff}
+ for name, masks in [('before',before),('after',after)]:
+ edge = [int((m & ~binary_erosion(m))[175:330].sum()) for m in masks]
+ switches = [int((masks[a]^masks[b])[175:330].sum()) for a,b in pairs]
+ report[name] = {'boundary_pixels': edge, 'switch_pixels':switches,
+ 'original49_boundary':summary(edge[:49]),
+ 'original49_boundary_jump':summary(np.abs(np.diff(edge[:49]))),
+ 'original49_switch':summary(switches[:48]),
+ 'all_boundary':summary(edge), 'all_boundary_jump':summary(np.abs(np.diff(edge + [edge[0]]))),
+ 'all_switch':summary(switches),
+ 'joins_switch':[switches[48],switches[97]]}
+ # Warp both alpha candidates with the same image motion. Residuals separate
+ # mask switching from unaligned silhouette motion (not an anatomy metric).
+ compensated = {'before':[], 'after':[]}
+ rows, cols = np.meshgrid(np.arange(155), np.arange(512), indexing='ij')
+ for a,b in pairs:
+ flow = optical_flow_tvl1(rgb2gray(images[a][175:330]).astype(np.float32),
+ rgb2gray(images[b][175:330]).astype(np.float32),
+ num_warp=3, num_iter=5)
+ for name,masks in [('before',before),('after',after)]:
+ warped = map_coordinates(masks[b][175:330].astype(float),
+ [rows+flow[0],cols+flow[1]], order=1, mode='nearest')>0.5
+ compensated[name].append(int((masks[a][175:330]^warped).sum()))
+ report['flow_compensated_switch']={k:{'all':summary(v), 'original49':summary(v[:48]),'per_pair':v}
+ for k,v in compensated.items()}
+ # Raw interior stays paired with alpha output; encoding error is reported separately.
+ with av.open(str(TASK/'reading-alpha.webm')) as src:
+ decoder=av.CodecContext.create('libvpx-vp9','r')
+ decoded=[f.to_ndarray(format='rgba') for p in src.demux(video=0) for f in decoder.decode(p)]
+ report['alpha_rgb_interior_mae']=summary([
+ float(np.abs(out[:,:,:3].astype(float)-im).mean(axis=2)[m>250].mean())
+ for out,im,m in zip(decoded,rgb,mattes['after'],strict=True)])
+ (TASK/'metrics.json').write_text(json.dumps(report,indent=2))
+ print(json.dumps({k:v for k,v in report.items() if k not in ['before','after','head_mae_all_pairs','flow_compensated_switch']},indent=2))
+ print(json.dumps({k:{x:y for x,y in v.items() if not isinstance(y,list)} for k,v in report.items() if k in ['before','after']},indent=2))
+ ids=[46,47,48,49,50,51,94,95,96,97,0,1]
+ panel([Image.fromarray(images[i]) for i in ids], [f'frame {i}' for i in ids],TASK/'join-frames.png')
+ panel([Image.fromarray(images[i][50:180,230:340]) for i in ids], [f'head {i}' for i in ids],TASK/'join-heads.png',size=(220,260))
+ ids=[0,8,16,24,32,40,47,48,49,56,64,72,80,88,96,97]
+ crops=[]
+ labels=[]
+ for i in ids:
+ for name,masks in [('before',mattes['before']),('after',mattes['after'])]:
+ rgba=np.dstack((rgb[i],masks[i]))
+ bg=Image.new('RGBA',(552,552),'#557788')
+ bg.alpha_composite(Image.fromarray(rgba))
+ crops.append(bg.convert('RGB').crop((90,189,430,356)))
+ labels.append(f'{name} {i}')
+ panel(crops,labels,TASK/'hand-before-after.png',size=(340,167),columns=4)
+ panel([Image.fromarray(mattes['after'][i]).convert('RGB').crop((90,189,430,356)) for i in ids],
+ [f'alpha {i}' for i in ids],TASK/'hand-mattes.png',size=(340,167),columns=4)
+ panel([Image.fromarray(images[i]) for i in range(98)], [str(i) for i in range(98)],
+ TASK/'all-frames.png',size=(170,170),columns=14)
+
+
+if __name__=='__main__':
+ main()
diff --git a/samples/bedroom/tools/stabilize_loop.py b/samples/bedroom/tools/stabilize_loop.py
new file mode 100644
index 0000000..95b4fb6
--- /dev/null
+++ b/samples/bedroom/tools/stabilize_loop.py
@@ -0,0 +1,146 @@
+"""Assemble the generated forward/return clips and stabilize only the hand/book matte.
+
+Run with the existing ComfyUI environment; no models or dependencies are fetched.
+Optical flow aligns neighbouring masks; only alpha changes, never source RGB.
+"""
+import json
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import av
+import numpy as np
+import torch
+from extract_layers import load_sam, segment
+from PIL import Image
+from scipy.ndimage import distance_transform_edt, gaussian_filter, label, map_coordinates
+from skimage.color import rgb2gray
+from skimage.registration import optical_flow_tvl1
+
+ROOT = Path('/media/lucian/Downloads/ComfyUI')
+TASK = ROOT / 'output/hel712-task'
+BASE = ROOT / 'output/hel711-task'
+Y0, Y1 = round(175 * 552 / 512), round(330 * 552 / 512)
+
+
+def decode_alpha(path):
+ with av.open(str(path)) as source:
+ decoder = av.CodecContext.create('libvpx-vp9', 'r')
+ return [f.to_ndarray(format='rgba') for p in source.demux(video=0)
+ for f in decoder.decode(p)]
+
+
+def encode(path, frames, alpha):
+ with av.open(str(path), 'w') as output:
+ stream = output.add_stream('libvpx-vp9', rate=16)
+ stream.width = stream.height = frames[0].shape[0]
+ stream.pix_fmt = 'yuva420p' if alpha else 'yuv420p'
+ stream.options = {'crf': '10', 'b': '0', 'auto-alt-ref': '0'}
+ for frame in frames:
+ for packet in stream.encode(av.VideoFrame.from_ndarray(
+ frame, format='rgba' if alpha else 'rgb24')):
+ output.mux(packet)
+ for packet in stream.encode():
+ output.mux(packet)
+
+
+def signed(mask):
+ binary = mask > 127
+ return distance_transform_edt(binary) - distance_transform_edt(~binary)
+
+
+def stabilize(images, masks):
+ # Padding supplies flow context, but edits are restricted to the specified band.
+ top, bottom = Y0 - 24, Y1 + 24
+ gray = [rgb2gray(im[top:bottom]).astype(np.float32) for im in images]
+ distances = [signed(m[top:bottom]) for m in masks]
+ rows, cols = np.meshgrid(np.arange(bottom-top), np.arange(552), indexing='ij')
+ def one(i):
+ mask = masks[i]
+ aligned = [distances[i]]
+ for offset in [-2, -1, 1, 2]:
+ j = (i + offset) % len(masks)
+ flow = optical_flow_tvl1(gray[i], gray[j], attachment=15,
+ num_warp=5, num_iter=10)
+ aligned.append(map_coordinates(distances[j], [rows+flow[0], cols+flow[1]],
+ order=1, mode='nearest'))
+ # Trim the most extreme masks; averaging the remaining three signed
+ # distances also avoids a majority-vote jump at the two clip joins.
+ consensus = np.sort(aligned, axis=0)[1:-1].mean(axis=0)
+ # A narrow spatial regularization removes single-pixel segmentation teeth.
+ stable = gaussian_filter(consensus, 0.65)
+ current = distances[i]
+ stable = np.where(np.abs(current) <= 6, stable, current)
+ alpha = np.clip(127.5 + stable * 127.5, 0, 255)
+ # Blend the first/last 8 rows back to the unchanged matte.
+ weight = np.ones(Y1-Y0)
+ weight[:8] = np.linspace(0, 1, 8)
+ weight[-8:] = np.linspace(1, 0, 8)
+ new = mask.copy()
+ roi = alpha[Y0-top:Y1-top]
+ new[Y0:Y1] = np.rint(roi*weight[:, None] + mask[Y0:Y1]*(1-weight[:, None])).astype(np.uint8)
+ return new, int(np.count_nonzero((new>127) != (mask>127)))
+
+ output, changes = [], []
+ with ThreadPoolExecutor(max_workers=4) as executor:
+ for i, (mask, change) in enumerate(executor.map(one, range(len(masks)))):
+ output.append(mask)
+ changes.append(change)
+ if i % 8 == 0:
+ print(f'Flow-aligned matte {i}/{len(masks)}', flush=True)
+ return output, changes
+
+
+def main():
+ torch.set_num_threads(8)
+ cache = TASK / 'segmentation-v2.npz'
+ if cache.exists():
+ data = np.load(cache)
+ images, masks = list(data['images']), list(data['masks'])
+ else:
+ # Preserve original matte outside the hand/book band without rerunning it.
+ original = decode_alpha(BASE / 'reading-alpha.webm')
+ with av.open(str(BASE / 'reading_00001_.webm')) as source:
+ images = [np.array(f.to_image().resize((552, 552), Image.Resampling.LANCZOS))
+ for f in source.decode(video=0)]
+ masks = [f[:, :, 3] for f in original]
+ model = load_sam(ROOT / 'custom_nodes/was-node-suite-comfyui/repos/SAM/segment_anything',
+ ROOT / 'models/sams/sam_vit_b_01ec64.pth').to('cuda')
+ with av.open(str(TASK / 'return_00001_.webm')) as source:
+ for i, frame in enumerate(source.decode(video=0)):
+ image = frame.to_image().resize((552, 552), Image.Resampling.LANCZOS)
+ images.append(np.array(image))
+ alpha = np.array(segment(model, image, extra_positive=(
+ (245, 258), (280, 275), (360, 245), (365, 295))))
+ components, count = label(alpha > 127)
+ sizes = np.bincount(components.ravel())
+ sizes[0] = 0
+ keep = components == sizes.argmax()
+ # Retain feather next to the connected subject; discard isolated sofa specks.
+ from scipy.ndimage import binary_dilation
+ alpha[~binary_dilation(keep, iterations=3)] = 0
+ masks.append(alpha)
+ if i % 8 == 0:
+ print(f'Segmented return frame {i}', flush=True)
+ np.savez_compressed(cache, images=np.array(images), masks=np.array(masks))
+ del model
+ torch.cuda.empty_cache()
+ stable, changes = stabilize(images, masks)
+ np.savez_compressed(TASK / 'mattes.npz', before=np.array(masks), after=np.array(stable))
+ encode(TASK / 'loop-source.webm', images, False)
+ frames = [np.dstack((rgb, a)) for rgb, a in zip(images, stable, strict=True)]
+ encode(TASK / 'reading-alpha.webm', frames, True)
+ (TASK / 'stabilization.json').write_text(json.dumps({
+ 'frames': len(images), 'fps': 16, 'join_pairs': [[48,49],[97,0]],
+ 'source_map': [{'frames':[0,48], 'source':'reading_00001_.webm'},
+ {'frames':[49,97], 'source':'return_00001_.webm'}],
+ 'band_512': [175,330], 'band_552': [Y0,Y1],
+ 'flow': 'TV-L1, four neighbours, cyclic trimmed signed-distance mean, 6px uncertainty band',
+ 'changed_binary_pixels_per_frame': changes,
+ 'rgb': 'Original RGB retained; no temporal RGB blending or frame replacement',
+ 'outside_band_unchanged': all(np.array_equal(a[:Y0],b[:Y0]) and np.array_equal(a[Y1:],b[Y1:])
+ for a,b in zip(masks,stable,strict=True))}, indent=2))
+ print('Encoded 98-frame forward/return candidate, not yet accepted.', flush=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/samples/bedroom/tsconfig.json b/samples/bedroom/tsconfig.json
new file mode 100644
index 0000000..6fd0535
--- /dev/null
+++ b/samples/bedroom/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": [
+ "src"
+ ]
+}