Files

373 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# STATE — 樊登读书会媒体库转码(2026-10-13 更新)
给下一个会话的交接。**权威运行器:`scripts/library_run.py`**(不要再恢复旧的 `hevc_lib.py`)。
## 2026-10-11:画质策略落地(闸门对齐 / 门槛 / VMAF)—— 先读这一节
**结论:闸门以前把帧配错了对**,靠它判死过 14 个健康编码(9.6 h 里烧掉 12.4 h lane 时间、13 项被 park)。
完整细节、验收数字与决策台账(D-01…D-06)在队列仓 `docs/QUALITY-POLICY-PLAN.md`;本节只记引擎侧改了什么、怎么复现。
**改了什么**(`scripts/library_run.py`,commit `7d16fed`)
1. **偏移搜索**(`ssim_vs_source`):原来是 `range(-100, 101, 10)` —— 步长 10,**永远试不到 ±1/±2**;而本库实测位移是恒定
**−1 帧**(`-fps_mode cfr` 在丢帧处插重复帧;1499/1500、1495/1500 帧都是 −1)。现在三段:**细搜步长 1、±6 帧** →
粗搜步长 10、±100(原样保留)→ **逐帧 ±2 最佳匹配**(`ALIGN_FINE` / `ALIGN_PERFRAME`);命中即停,判定行写明走了哪一段。
2. **负偏移方向修好**:旧代码 `f.seek(max(0, off) * fs)` 只把参考往前挪,于是**所有负偏移与 off=0 同分** ——
日志里那句 "verified at −100 frames" 其实什么都没对齐。
3. **临时文件带 PID**(两条 lane 共用暂存目录,固定名会互踩);`pair_*.log` 解析完即删(否则全库一轮攒出 GB 级垃圾)。
4. **窗口解码重试一次 + 保留 ffmpeg stderr**(进 `stats["decode_error"]`,由 FAIL 信息带出):一次偶发解码失败曾白扔一个
已经跑完的 16.7 分钟编码,而旧代码把报错丢掉、只剩 `FAIL ssim: no frames compared`。闸门解码另用
`GATE_STALL_S = 3600`(`trim` 到窗口前本来就不写任何字节,用编码器的 900 s 阈值会误杀)。
5. **两处 SSIM 滤镜加 900 s 超时** —— 卡死的 ffmpeg 不再能永久堵住一条 lane(今天确有一个空转烧 4500 s CPU 的例子)。
6. **门槛**:`SSIM_MEAN_MIN 0.98 → 0.995`、`SSIM_MEAN_MIN_REDO 0.975 → 0.990`、新增 `SSIM_MEAN_MIN_FALLBACK 0.990`
(业主释放项走第二档,原先是让 mean **完全失效**);per-frame floor 0.985 与坏帧占比 ≤1% 不变。
触发对齐搜索的条件也改成按**实际门槛**(原硬编码 `mean < 0.985`,门槛一升就会出现"没触发搜索就判死"的新冤案)。
7. **对齐说明不再绕过判定**:它原来走第 4 个返回值(调用方当作 `irreg` = 时间轴不规则 → **跳过整条画面判定**),
canary 与两个 x265 项就是这么通过的(mean 门槛从没生效)。现在只进 `stats["align_note"]` + 日志,门槛照常执行。
8. **VMAF observe-only**(`VMAF_OBSERVE`):在同一对齐偏移上算,判定行带 `vmaf480 mean/min` —— **480p 尺度**;
同一窗口原生读 95.85/93.76、480p 读 98.27/96.97(+2.4/+3.2),所以标了尺度;**从不参与判据**。
**怎么复现验收**(队列仓内,只用本地缓存产物,不碰库)
```bash
python cache/lean_verify.py # sign 双向 / 旧值 0.9808 复现 / 细搜找到 −1 / 错画面簇仍被拒
python cache/vmaf_native.py --original <快照原档> --converted <线上档> --offset -1 # 原生分辨率 VMAF 标定
```
**实测锚点**:`危机领导力` 索引配对 0.9808 → 对齐 **0.9976**;`爆款` 0.9862 → **0.9974**;canary `上瘾` 0.9662 → **0.9968**
(装盘 + 安装后回读 SHA-256 一致)。
⚠ 比较 mean 必须同帧数:偏移搜索上限 1200 帧 —— 同一配对 1500 帧读 0.9808、1200 帧读 0.9832。
⚠ 原生 ffmpeg/ffprobe 读不了 CJK 路径(`Illegal byte sequence`),复核线上档要用 Python `subprocess` 传路径。
**现在由队列仓控制**(`D:\dev\hoelee-resource-transcode`:supervisor 看板 8790 + 三 lane)。2026-10-11 13:35 业主恢复转换,
cpu + gpu 两条 lane 在跑(`autostart_lanes=[cpu,gpu]`)。**本仓不要自己起编码** —— 引擎只作为 lane 的子进程被调用,
单档启动器(`run/relaunch_x265.sh` 等)只在看板不参与时应急用。
## 2026-10-13:闸门配对修复 / preset 可选 / 子进程亲和真相 / run_pinned 事故
(控制面侧的对应记录见 `D:\dev\hoelee-resource-transcode\docs\OPERATIONS.md` 同名小节)
### 1. `run_pinned` 把 `capture_output` 传给 `Popen` → 三个 lane 秒失败 64 项(`eb4b0bd`)
引入子进程包装时把 `subprocess.run` 的语法糖 `capture_output` 直接透传给了 `Popen` ——
`Popen` 不认这个参数,`run()` / `_sh` / 三个 ffmpeg 调用点全部 `TypeError`。因为失败是**秒级**的,
队列会立刻重投,2.5 分钟内 **64 项把 3 次 attempts 烧光变 failed**。
修法:在包装里展开 `capture_output=True` → `stdout/stderr=PIPE`。
**修复被误判的项要精确**:`UPDATE files SET state='pending', attempts=0, fail_gear=NULL WHERE last_error LIKE '%capture_output%'`
(按错误文本定位;不要整批重置 failed,那会掩盖真失败 —— 例如 `PermissionError [WinError 32]` 是真失败)。
### 2. 闸门:对齐修好了配对,判决却用旧配对的帧占比(`767fa3a`,业主的假拒绝)
`ssim_vs_source` 一直在做三阶段对齐搜索(fine ±6 / coarse ±100 / per-frame ±2),
但 **触发条件只看 `min < 0.985` 和 `mean < 0.995`,漏了帧占比**;而且采用对齐结果时只更新了
`lo/mean/n`,**判决消费的 `stats["below"]` 仍是直配的计数** → 于是出现
`mean 0.9974(对齐后)但 20/1199 帧低于 0.85(直配)` 这种自相矛盾的判决,把 `0912 洞见` 反复拒掉。
改动:
- 触发条件加上 `bad > MAX_BAD_SHARE * n`;
- `_paired_ssim` / `_perframe_best` 现在返回 `below` 计数,并且**同样剔除近平坦帧**
(之前对齐后的 min/mean 含黑场帧,会让对齐看起来比直配还差);
- 采用对齐结果时同步 `stats["below"]`,并打印 `X frames below before, Y after`。
**验证方法(便宜且可复用,全部本地、不需真编码)**:同一个 60 s 窗口造三份夹具 ——
健康对(应走快路径、0 坏帧)、**错位 1 帧的对**(`trim=start_frame=1`;实测
`alignment repaired the pairing: 20 frame(s) below 0.85 before, 0 after`,与判决书 20/1199 逐项吻合)、
真缺陷对(`hflip=enable='between(n,250,400)'`;mean 0.9612 / 151 帧 10.07% **仍被拒** ✓ 没变橡皮章)。
脚本模式见 `C:/Users/hoelee/AppData/Local/hermes/cache/scratch/gate_fix_test.py`。
生产验收(`0912 洞见`,crf 24):`ssim min 0.9936 mean 0.9971 (1199 帧)` → `ok 580 MB -> 422 MB (73%)`。
### 3. 子进程亲和的真相(Windows)—— 别再指望"子进程继承 mask"
- **`CreateProcess` 不继承** affinity mask(Linux 的 `sched_setaffinity` 才继承)→ 引擎的
`pin_to_e_cores()` 只覆盖引擎自己;实测三个 ffmpeg 全在 `0x3FFFFFF`(26 核)。
- **事后补钉也不行**:对已跑的 ffmpeg 调 `SetProcessAffinityMask` 与 `AssignProcessToJobObject`
都返回 `ERROR_ACCESS_DENIED(5)`(x265 自己为 NUMA 设线程亲和),而同一个调用打在 `ping`
子进程上成功 → 是 ffmpeg/x265 拒绝,不是权限问题。
- **唯一可行的机制 = Job Object 的 `JOB_OBJECT_LIMIT_AFFINITY`**:子进程创建时**继承 job 成员资格**,
调度器对所有后代强制执行。控制面因此把引擎所在 job 带上亲和限;引擎里的 `pin_child()`
只是 best-effort(对非 ffmpeg 子进程有效)。
- **验证必须读 ffmpeg 子进程的 `ProcessorAffinity`**,不是引擎的、也不是看板上的。
- 代价实测:同片段 pools=8,限核 11.2 s vs 不限 11.7 s(噪声内),输出字节一致。
### 4. preset 现在可由控制面指定(`5e27ede`)
新增 `--x265-preset` / `--nvenc-preset`(控制面按设置传入;常量只是手跑时的回落值)。
**NVENC 默认 p6 → p7**(业主决定),x265 保持 `medium`。
x265 实测(crf 24、pools=2、1080p 60 s 窗口,耗时 = 编码器自报秒数 ±1 s):
| preset | 体积 | 帧率 | 耗时 | SSIM mean |
|---|---|---|---|---|
| ultrafast | 6.05 MB | 25.0 | 60 s | 0.9952 |
| veryfast | 7.58 MB | 16.7 | 90 s | 0.9968 |
| fast | 7.90 MB | 12.5 | 120 s | 0.9969 |
| **medium(现用)** | 8.11 MB | 12.5 | 120 s | **0.9971** |
| slow | 9.00 MB | 5.0 | 300 s | 0.9975 |
**固定 CRF 下换 preset 不会让文件变小**(±1–2%,另一个窗口方向甚至反转)→ 省体积要动 CRF,
preset 是画质微调;`ultrafast` 的 SSIM 0.9952 紧贴 0.995 门槛(会把项推去重试),`slow` 是 2.5× 时间换 +0.0005。
nvenc(cq 31):p1/p2/p3 10.23 MB · p4/p5 9.49 · p6 9.44 · p7 9.41(同 cq 下 SSIM 全 0.9973);
精确计时 p4 263 fps / p6 139 fps / p7 128 fps → **p7 = p4 的 2.05 倍时间换 0.9% 体积**。
⚠ **计时坑**:经引擎跑编码时,stall 看门狗的 30 s 轮询粒度会污染挂钟计时(两个不同 preset 都报 150.0 s)。
要精确比速度就绕开引擎直接调 ffmpeg + `perf_counter`。
## 现在在跑什么
**运行方式以控制面为准**:`D:\dev\hoelee-resource-transcode` 的 supervisor(看板 `http://127.0.0.1:8790/`)
负责排程;它拉起**三个并行的 x265 worker**(cpu / cpu2 / cpu3,核集互不重叠、各自 `--lock-tag`),
本文件旧版的"单档启动器"只是控制面缺席时的手跑后备方式。
- 2026-10-13 00:09 起:cpu 组是**「做完当前暂停」**状态(业主在看板上点的),cpu2/cpu3 空闲、cpu 在收尾。
恢复:看板「开始」或 `POST /api/lane/cpu/start`。
- ⚠ 别再手工起第二个同档实例去"补进度":控制面已经在跑,重复实例会撞暂存文件(见本文件 2026-10-13 第 3 节)。
以下为**手跑单档**(控制面不在时)的原始记录:
**只有 x265 一档在跑**(2026-10-09 20:51:41 起,单档启动器 `run/relaunch_x265.sh`)。
**nvenc 档故意不启动** —— GPU 被 TubeSync 的 58 个文件修复批次占着(20:36 起,与铁律一致:同一颗编码器不能两个任务)。
```bash
# 只起 x265(E 核,纯 CPU)—— 仓库源 run/relaunch_x265.sh 拷到 /d/tmp/xcode/ 再跑
powershell -NoProfile -Command 'Start-Process -FilePath "C:\Program Files\Git\bin\bash.exe" -ArgumentList "D:/tmp/xcode/relaunch_x265.sh" -WindowStyle Hidden'
# ⚠ 参数必须只有脚本路径;写成 -ArgumentList "-lc","bash <脚本>" 会起一个交互式 bash,什么都不跑
tail -f /d/tmp/xcode/run_x265.log
```
### 2026-10-09 16:51:58:两档是**整机重启**停的,不是 bug
(两档都不在 Hermes 的进程管理里 → `process_manage` 看不到,也没有退出通知:只能看日志,按 PID 杀。)
`LastBootUpTime = 2026-10-09 16:51:58`;x265 日志停在 16:50:42(404/589)、nvenc 停在 16:51:30
(308/589)。11:48 由 relauncher 重新拉起、跑到重启为止。锁文件里那两个 PID(23792 / 50128)已是死号 ——
`transcode.Lock` 是**过期感知**的(age > 300 s 且 pid 已死即接管),不需要手工清锁。
重启后内存正常:物理空闲 12.9–13 GB、提交 50.3/55.9 GB(11:19 那次 MemoryError 时是 96.5%)。
⚠ 但提交上限从 79.6 GB 缩到 55.9 GB:D: 已 92% 满,pagefile 长不上去。
### 为什么改成"脱管"启动 —— 10:37 那次全停的教训(第 9 个 bug)
上一轮的 x265 / nvenc 是 Hermes 后台进程。**用户停会话时它们被一起杀了**:两个都停在编码到一半
(stage 里 `*_out.mp4` 和 `.err` 停在 10:37,日志里没有 `PASS-DONE`),所以 `persist_on_release` 在这个
环境里**不可靠**。现在两档由 `powershell Start-Process` 拉起的独立 bash 启动,父进程立刻退出 →
进程树成为孤儿,**Hermes 的清理碰不到它们**。
代价与规矩:没有退出通知(看日志);**停/杀只能按 PID**,绝不用进程名批量杀。
## 进度(2026-10-08 10:50,库内共 589 个文件)
| 档 | 已转好 | 待转 |
|---|---|---|
| copy | 160 | 4(见下) |
| x265 | 13 | 169 |
| nvenc | 25 | 174 |
| 无需处理(已达播发标准) | 44 | — |
| **合计** | **242** | **347** |
- 预期:x265 ~3.5 天、nvenc ~3 天跑完各自那档(这一轮 10:43 才重新起来,10:37 被上面那次全停打断)。
- 台账:`C:\AI\transcode-run\ledger.jsonl`(每项一行:分类/结果/大小/SSIM)。
- 进度只认台账与 state,**不看 `.mp4` 扩展名**(TubeSync 重命名会把它改回 `.mkv`)。
## 2026-10-08 新增的 4 集"每周更新"(copy 档)
`1004 反内卷` `0912 感官觉醒` `0905 如何成为情绪稳定的父母` `0822 超赞的一代`(都在 `樊登读书2026年(更新中)`):
H.264 + **mp3** 音轨,其中 `0822 超赞的一代` 其实是 **MPEG-TS** 只是挂着 .mp4 名 → iPad 播不了,
需要 copy 档(视频逐位不变 + 音轨转 AAC)。
⚠ **不要跑整轮 `--tier copy`**:copy 档唯一的跳过判据是"已经是 HEVC",而 copy 的产物是 H.264 ——
整轮会把 193 个已转好的 copy 文件全部重做一遍。改用单文件通道:
```bash
bash /d/tmp/xcode/convert_new4.sh # 逐个 --tier copy --path <file> --keep-source,共用 run-copy.lock
```
- `1004 反内卷` 10:48:53 完成 ✓(535 MB → 535 MB,视频 100% 逐位不变,音频转 AAC,149 s,rc=0)
## 闸门策略文件(业主决定 2026-10-10):`C:\AI\transcode-run\gate_policy.json`
8 个项在 nvenc 档里反复失败、**每轮白烧 ~3.3 GPU 小时**(初始一次 + quality+2 一次),而且永远过不了:它们的
mean 差 0.2–1.7 个千分点(0.9626–0.9778),但**帧占比只有 0.20–0.93%** —— 按本仓库自己的注释,抓真缺陷的是
**帧占比 >1%** 那条,mean 那条只是在切一条连续分布。
```json
{
"accept": [ "7 个项(帧占比 ≤1%)的库内相对路径" ],
"park": { "8 个项": 1 }
}
```
- `accept`:名单内的项以**帧占比测试**为准,mean 低于门槛只记录不拦截。**帧占比 >1% 的项即使列在名单里也照样拒**
(《2020年\0912 洞见》1.33% 故意不在名单里)。
- `park`:每项**失败轮数**;达到 `PARK_AFTER = 2` 就 **SKIP**(不再重编码),要重试就把它的条目删掉。
没有这条,同样的 8 个项每趟都重演一次、永远转不出来。
- 生成器:`scripts/build_gate_policy.py`(键**逐字取自日志**的 `--- i/n <相对路径>` 行,不手抄)。
- 停/起的单档启动器:`run/relaunch_nvenc.sh`(`relaunch_tiers.sh` 会连带拉起 x265,别用它单独起 GPU 档)。
## 图片闸门:re-do 用 0.975(业主决定 2026-10-08)
第一轮实测:一趟里 11 次编码**只差在 mean**(0.9655–0.9795)就被拒,而被接受的那些帧最低分反而更差
(0.33–0.90)—— 阈值在切一条连续的分布,不是在分开缺陷(`0718 马斯克原理`:min 0.887、**0.00%** 帧
<0.85,照样被拒)。被拒的文件本来就已能播(HEVC hvc1 + AAC),re-do 的唯一目的是清掉旧设定的膨胀;
而要够到 mean 0.98 得花掉超过源片自身的码率,会被 size ceiling 拦住 —— 结果是文件连同膨胀被永久搁置,
而且每一趟还白烧一次编码。
新规则(`library_run.SSIM_MEAN_MIN_REDO = 0.975`):**只有**"重做一个已经在播的文件"才用 0.975,条件为
live = HEVC hvc1 **且**音轨是 AAC(真能播)**且**本次是从快照原档重做;首次转换仍用 0.98。
帧占比判据(>1% 帧 <0.85)两档都不变 —— 它才是抓真缺陷的那条:`失控`(0.9655)仍会被拒,
`0808 陌生人效应`(0.9739)因为 live 是 H.264、根本不是 re-do,也仍走 0.98。
先在一个文件上验证(`0321 随他们去`,上一轮 mean 0.9789 被拒)→ 见 `/d/tmp/xcode/run_pilot.log`。
## 目录与日志
| 用途 | 路径 |
|---|---|
| 运行日志 | `/d/tmp/xcode/run_x265.log`、`run_nvenc.log`、`run_copy.log`、`run_new4.log` |
| 脱管启动器 / 新集转换 | `/d/tmp/xcode/relaunch_tiers.sh`、`convert_new4.sh`(+ `relauncher.out`);**源在仓库 `run/`**(改仓库那份再拷出来) |
| 单文件日志 | `/d/tmp/xcode/run_single.log` |
| 暂存目录(编码/闸门 raw) | `C:\AI\transcode-run\stage`(C: 盘,要留 ≥8 GB) |
| 台账(每项一行 JSON) | `C:\AI\transcode-run\ledger.jsonl` |
| 锁(每档一个 + 心跳) | `C:\AI\transcode-run\run-<tier>.lock` |
## 本轮修掉的 bug(按时间顺序,全部已提交推送)
1. **闸门判据太粗糙** → 改"缺陷形状"判:`mean < 0.98` 或 `>1% 帧 <0.85` 才判错,并**剔除近平坦帧**(SSIM 在黑场/淡入淡出上无意义;实测:好编码 0/1500、误判文件 0.33%、真缺陷 2.9%)。
2. **闸门用输入 seek 解码** → 改**两侧顺序解码**(open GOP 源从中间 seek 会解出错误画面:同一文件与自身比对 min 0.25)。
3. **闸门 raw 文件固定名** → 三个档并行时互踩(实测报出"100% 帧不符"的假缺陷)→ 改 **PID + tag** 命名。
4. **安装只用 ffprobe 元数据复核** → RaiDrive 返回**缓存属性**,rename 没落地也说成功(实测"ok 1085→478 MB"而线上仍是原档)→ 改**安装后回读哈希**。
5. **源拷贝只校验字节数** → 加 **SHA-256 比对共享盘副本**(长度对内容错是真风险)。
6. **probe 单次失败即判文件失败** → 加**重试 3 次**。
7. **看门狗用 stderr 管道**(本轮最贵的 bug)→ ffmpeg `-stats` 每 0.5 s 一行,64 KB 管道几分钟塞满 → ffmpeg 阻塞在 write() → 输出不增长 → 看门狗**误杀好编码**(整夜 ~270 杀、只转成 24 个、GPU 0%)。修:**stderr 写文件**,阈值 5→15 分钟。自检脚本 `D:\dev\hoelee-resource-transcode\cache\watchdog_selfcheck.py`。
8. 看门狗**保留**(它要防的是真实场景:用户更新 NVIDIA 驱动会把在跑的 NVENC 冻死 1.5 小时且不报错)。
9. **Hermes 后台进程会随会话一起被杀** —— 两个档在 10:37 同时死在编码中,日志里没有 `PASS-DONE`;改脱管启动(见上)。
## 未完成 / 待办
- [ ] **等业主定 D-07(队列仓 PLAN §7)**:`0705机械宇宙` 按指示跑过 `--crf 24`,仍是 mean **0.9120**、坏帧 **8.93%**、
`vmaf480` **59.41** —— 与 crf29 逐位相同 ⇒ **源档缺陷**(引擎自己标了 non-uniform cadence),别再降参数;
要么接受原档,要么单独走"源档缺陷"口径。同类还有 3 项 `engine-skip`(park 满 2 轮)。
- [ ] **cpu(x265)档门槛问题**:crf29 在 bpp 0.0147–0.0293 带成立(0.9958/0.9968),但 **bpp ≳0.05 那一带 4/4 落到
0.9926–0.9946**(720p、源 ~1.17 Mbps)→ 每项白烧 2 次整片编码后 park。选项 A `cpu_crf→24` / B 放行到 0.990。
- [ ] 两档跑完 → **全库落地审计**:`D:\dev\hoelee-resource-transcode\cache\install_audit.py`(核对线上文件真实大小 + 编码器;曾一次抓到"报告成功没落地")+ `copy_land_check.py`(copy 档用音轨 aac/mp3 判定)。
- [ ] 失败项:无需清单,新一轮自动重做(失败时文件原样)。
- [ ] `2022年/0723 《走出强迫症》/走出强迫症.mp4` 用新下载(`D:\Download\0723走出强迫症.mp4`,618,696,954 B)已转好装好(450,502,893 B,HEVC+AAC,SSIM min 0.997,时长 3399.81 s)✓。**快照里那份仍是坏的**(618,697,011 B,moov 缺失)→ 待定:是否用干净副本替换快照里的坏档,以免将来"从原档重转"踩坑。
- [ ] 目标文件夹里曾出现一个 0 字节的 `0723走出强迫症.mp4`(现已不在)—— 若是用户自建请告知命名约定。
- [ ] 全库唯一真损坏:`走出强迫症.mp4` 的原档(上面已处理);其余 573 个可读文件截断审计 0 可疑。
- [x] 暂存目录越堆越满(第 13 个 bug)→ 已修:`purge_item_artifacts` + `sweep_stage` + 队列仓 `rt/janitor.py`。
## 用法速查
```bash
# 单文件(任意位置)转好并装回库,走同一套分档/闸门/回读
python scripts/library_run.py --path "D:/Download/x.mp4" --dest "Z:/.../x.mp4" --keep-source
# 只审计不落地(本地编码+闸门,输出丢弃)
python scripts/library_run.py --tier x265 --limit 3 --keep-local
# 分档预览
python scripts/library_run.py --dry-run --limit 20
```
## 第 10 个 bug:MemoryError 会在共享盘上留孤儿(2026-10-09)
现象:11:19:31 与 11:19:37 连续两次 `!! FAIL MemoryError:`(消息空白),两次相隔 6 秒 ——
一次是《崇祯传》**装盘**(写 493 MB 到 RaiDrive),一次是下一项**读源**。
根因是**整机内存/提交额度耗尽**,不是文件问题:
```
物理可用 0.0 GB / 48 GB 已提交 76.8 GB / 上限 79.6 GB(96.5 %)
pagefile d: 初始 8 GB / 上限 32 GB —— 但 D: 只剩 19 GB(97 % 满),长不上去
大户:Chrome ≈4 GB、Hermes ≈3.7 GB(3 个进程)、upscayl 1.17 GB、whisper-server 0.83 GB、Discord 0.76 GB
```
`copy_*` / `_sha_remote` 当时只 `except OSError`,**而 `MemoryError` 不是 OSError** → 直接冒到
per-item 兜底;删半截 `remote_tmp` 的清理写在 `except OSError` 里 → **半截/完整的 `.new.mp4` 留在共享盘上**。
已修(本轮,`scripts/library_run.py`):
1. `_sha_remote` / `copy_remote_to_local` / `copy_local_to_remote` 的 `except OSError` → `except (OSError, MemoryError)`(瞬时抖动变重试,不是 FAIL);
2. `copy_local_to_remote` 内的清理、读回不符时的清理同样放宽;
3. **per-item 兜底加了无条件删除 `<dest>.new.mp4`**(用 `os.remove` + 忽略 `FileNotFoundError`,**不用** `os.path.exists`,因为该共享盘的元数据可能是陈旧的)。
实测(注入 MemoryError):上传路径不再抛异常、半截文件被删;读源路径重试后抛干净的 `IOError`;
`_sha_remote` 重试 2 次后抛出。
**内存仍是硬前提**:物理可用 0 GB / 提交 96.5 % 时重启,第一项照样会 FAIL(现在至少不留残留)。
重启前建议:关掉 upscayl / 多余 Chrome 标签 / Discord 腾出 5-6 GB;pagefile 上限别放在 97 % 满的 D:。
## 8 个 `.new.mp4` 残留(2026-10-09 11:35 逐个探过)
原档都在,没丢东西。7 个是**半截前缀**(内容早停 → 没用,删掉让下一轮重做);
**只有一个是完整的、可以救回来**:
| 文件 | 暂存 | 判定 |
|---|---|---|
| 2022年/0115《崇祯传》 | 493 MB | ✅ **完整**(3616.8 s 与源一致、内容到 3609 s)→ 校验后可改名安装 |
### 已处理(2026-10-09 12:10)
- **《崇祯传》已安装**:先过全部门才改名 —— 暂存档拷回本地并回读比对哈希(493,366,137 B,与共享盘副本一致)
→ 结构闸门 True → **全解码 True**(唯一能证明没截断的检查)→ 图片闸门 **min 0.9980 / mean 0.9988(1500 帧,
0 帧 < 0.85,远高于 0.98 的杆)** → `os.replace` 改名 → 安装回读 `sha256 5a903ab8a3d1`(493,366,137 B == 本地)
→ 替换后闸门 hevc/aac、tag=hvc1、dur 3616.8 ✓。**省下一次 3616 秒的重编码**(822,288,651 B h264 → 493,366,137 B HEVC)。
- **7 个半截残留已删**(8 MB–165 MB,都是内容早停的前缀):0511原生家庭、20190601被管教的勇气、0622人生海海、
0807东京传、0527 洪业、» 0819 李白、1230 不安的哲学。**它们对应的 live 原档一个没动**,下一轮自动重做。
- 共享盘上 `*.new.mp4` 现在 **0 个**。
| 0622人生海海 / » 0819 李白 / 0527 洪业 / 0807东京传 / 20190601被管教的勇气 / 1230 不安的哲学 / 0511原生家庭 | 8 MB–165 MB | ❌ 半截,删 |
## 第 12 个 bug:解除 park 被"正在跑的引擎"改回去(2026-10-11)
现象:13:47 解除了 `0705机械宇宙` 的 park 条目(业主"降参数"决定),13:59 这一项还是被 **SKIP parked: 2 rounds**,
而且 `0808 陌生人效应` 的条目莫名出现。
根因:`save_policy()` 把**内存里的整份快照**写回文件。13:35 起跑的那个引擎进程在 13:35 读的 policy 里
`机械宇宙` 还是 2;它自己的项在 13:5x 失败时就把快照整份写回 → 外部刚删掉的条目复活(并顺手写入自己的 1)。
修(`scripts/library_run.py`):`save_policy` 改成**读-改-写**——先重新读盘,再只覆盖本进程真正 +1 的计数
(`POLICY_DIRTY`),`accept` 一律以盘上为准。回归:`scripts/test_policy_merge.py`(4 项:放行项保持放行 / 外部新增
accept 保住 / 计数落盘 / 旧行为确实会把它复活)。
⚠ 顺序仍然重要:**解除 park 前先 `pause-after` 把 lane 停到 item 边界**,别指望"改得够快"。
## 第 11 个 bug:pythonw 下每个 ffmpeg/ffprobe 子进程弹一个终端窗口(2026-10-11)
现象:转换一跑,屏幕时不时冒出一个命令行/终端窗口,一项里出现多次。
根因:队列把引擎(`library_run.py`)以 **`pythonw.exe`** 启动 —— GUI 子系统,**完全没有控制台**。
Windows 的规则是:控制台子进程若父进程没有控制台,系统就**新建一个控制台并显示出来**。
所以引擎里每一次 `ffprobe`/`ffmpeg` 调用(probe、编码、闸门的 SSIM/pair/VMAF)都弹一个窗口;
Win11 默认终端是 Windows Terminal,所以看到的是"终端窗口"(类名 `CASCADIA_HOSTING_WINDOW_CLASS`),不是老式 cmd 窗口。
修(`library_run.py` + `transcode.py`):每个 `subprocess` 调用都带 `creationflags=CREATE_NO_WINDOW`
(`transcode.run`、`library_run.run`、`run_watched`、以及三处直接调 ffmpeg 的闸门助手)。
队列仓的 `scripts/tsconv.py` / `scripts/tsfix.py` 同病同修(各加一个默认带 flag 的 `run()` 包装)。
验证(队列仓 `scripts/nowin_popup_launch.py` → `nowin_popup_test.py`):必须用**没有控制台的父进程**
(从 shell 起 pythonw 会继承那个 shell 的控制台 → 假阴性),脚本先断言 `GetConsoleWindow()==0`,
再数可见的 `ConsoleWindowClass` + `CASCADIA_HOSTING_WINDOW_CLASS`:裸 `Popen` 会多出 1 个窗口,
带 flag 的(含全部 patched helper)保持 0。实测 2026-10-11:0→1 / 0 / 0 / 0 / 0 / 0。
## 第 13 个 bug:闸门 raw 没人收,暂存目录越堆越满(2026-10-12)
现象:`C:\AI\transcode-run\stage` 里积了 6 个 291 MB 的 `ssim_<pid>_<tag>_{ref,out}.yuv` + 123 个 `ssim_<tag>.log`。
正常收尾只删 `<tag>_src`/`_out.mp4`,**闸门的 raw / pair / vmaf / stats 日志没人删**;被暂停、重启、看门狗杀掉的项
留下的文件更是永远没人收。加上项目历史遗留的 45 GB 测量垃圾(`C:/AI/transcode-run/*` 与 `D:/tmp/*`),
work 盘只剩 3.9 GB → 队列守卫拒绝所有新项,两条 lane 空转 30+ 分钟。
修:
1. `process()` 的 `finally` 加 `purge_item_artifacts(tag)` —— 按 tag(stage hash)与 pid 删本项的 `ssim_/pair_/vmaf_` 产物,
另一条 lane 的文件名字里没有这些 key,绝不会误删。
2. 新增 `sweep_stage()`,每次启动扫一遍 stage:只删老于 `STAGE_MAX_AGE_H`(6 h,`RT_STAGE_MAX_AGE_H` 可覆盖) 的残留,
并且**整条 tag 跳过**(只要有新鲜的 `<tag>_out.mp4.err` = 那一项正在编码)。
3. 队列侧 `rt/janitor.py`(supervisor 每 30 分钟 + **守卫失败时立刻**清一次再复检)用同一套规则把 stage 和
`cache/runs/*.log` 收干净;`ledger*`/`*.lock`/`tsconv_state*`/`queue.sqlite*` 永不碰。
回归:`scripts/test_stage_cleanup.py` 6 项(另一 lane 不动 / 死残留删 / 在编 tag 保全 / 台账不动 / 字节数正确)。
## 红线(用户明确要求过)
- **绝不删除/覆盖快照原档**(`#snapshot` 是唯一的干净档案)。
- 失败时**不动 live 文件**;只有"结构 + 全解码 + 图片闸门 + 上传回读 + 安装回读"全过才替换。
- 杀进程**只按 PID**(绝不用进程名批量杀,`taskkill /IM ffmpeg.exe` 会连带杀掉别人的编码);一档一个锁;同一档不要跑两个实例。
- 动 GPU(更新驱动/跑 ComfyUI/加载大模型)前先暂停 nvenc 档。