# MiniMax H3 本地部署指南（鲲鹏920 + Ascend 910B3 ×8）

## 一、结论速览

| 项 | 结果 |
|---|---|
| 硬件 | 8 × Ascend 910B3（单卡 64GB HBM，共 512GB），全部健康 |
| CPU / 内存 | 鲲鹏920，1006GB RAM（989GB 空闲） |
| 模型 | `/home/models/MiniMax-H3`（约 200GB，含 FL2VA / Ref2VA 两套分区） |
| conda 环境 | **必须用 `minimax_h3`**（不是 `modelscope`） |
| 推理框架 | **diffusers 0.40.0** 内置 `MiniMaxH3ModularPipeline`；未装 SGLang/vLLM |
| **版本配套** | **CANN 9.0.0 必须配 torch_npu 2.9.x**（已从 2.5.1 升级） |
| 加载验证 | ✅ 8 个组件 134GB 跨 8 卡分片，每卡 15~28GB |
| 算子验证 | ✅ text_encoder 跨 7 卡前向、VAE Conv3D 解码均通过 |
| **一键启动** | **`./h3.sh up` → 浏览器 `http://<机器IP>:7860`**（常驻服务 + Web UI + 任务队列） |
| 一次性命令行 | `./h3.sh t2v "prompt"` / `./run_h3.sh --workflow t2va --prompt "..."`（老脚本仍可用） |
| 工作流手册 | **`MiniMax-H3-工作流手册.md`**（五种输入模式 / prompt 规则 / 后处理链 / 批量分镜 / API） |

---

## 二、六个必须绕开的坑

直接照官方 README 的命令跑会失败，以下是实际踩到并已解决的六个问题。
前两个是硬阻塞（跑不起来），后四个是跨卡分片特有的坑。

### 坑 1：torch_npu 与 CANN 版本必须配套 ⭐ 最关键

这是**唯一导致推理跑不通的硬阻塞**，也是最难定位的一个。

torch_npu 自带的官方配套表（`torch_npu-2.5.1.dist-info/METADATA`）：

| CANN Version | 支持的 PyTorch | Extension 版本 |
|---|---|---|
| **CANN 8.1.RC1** | **2.5.1** | **2.5.1** |
| CANN 8.0.0 | 2.4.0 | 2.4.0.post2 |
| CANN 8.0.RC3 | 2.4.0 | 2.4.0 |

本机 CANN 是 **9.0.0**（`/usr/local/Ascend/cann-9.0.0`），却装着 torch_npu **2.5.1**
—— 跨了一个大版本。表现是两个看似毫不相干的算子层报错：

**症状 A：跨卡分片前向失败**
```
Invalid_Argument(EE1001): BinaryGetFunction failed,
  kernel_name=Triu_9cada6f06376fa1b999483e5970057e7_high_performance__kernel0
Can not find kernel by name = Triu_..._high_performance__kernel0
Kernel launch from cache failed.  OpExecCache run fail.
[ERROR] ERR01005 OPS internal error   (Device:1)
```
触发位置是 Qwen3VL 的 `rotate_half` → `torch.cat((-x2, x1), dim=-1)`，
执行 `aclnnNeg` 时却去加载名为 `Triu` 的 kernel。

**症状 B：VAE 解码失败**
```
Compilation_Error_Execute_Fusion_Pass(E20007):
  Graph fusion pass Conv3dToConv3dV2FusionPass failed
Conv3dv2 only support static shape.
OPS function error: Conv3D, error code is 500002
```

**排查过程中排除掉的错误方向**（都没用，别浪费时间）：

| 尝试 | 结果 |
|---|---|
| `TORCHDYNAMO_DISABLE=1` | 无效 |
| `ACL_OP_SELECT_IMPL_MODE=high_precision` | 无效 |
| patch `accelerate.send_to_device` 改 `non_blocking=False` | 无效 |
| `torch_npu.npu.set_compile_mode(jit_compile=False)` | 无效 |
| `ENABLE_AUTO_FUSE=0` | 无效 |
| 关闭 VAE tiling（`vae.use_tiling=False`） | 无效 |
| 基础算子逐卡体检（add/neg/cat/matmul/triu/softmax/sdpa，8 张卡全测） | **全部通过** |
| 真实形状的 RoPE `(1,64,512,128)` + `triu(512,512)` 因果掩码 | **通过** |
| 裸 `nn.Conv3d` 六种 kernel/padding 组合 + 真实规模 `(1,24,32,48,48)` | **全部通过** |
| 最小跨 3 卡模型 `dispatch_model` 前向 | **通过** |

**关键判据**：所有孤立算子测试都通过，只有真实模型跑起来才失败 —— 这种
"单点正常、组合失败"的形态，基本可以直接指向运行时版本不配套，而不用再逐个算子猜。

**解决**（清华镜像可达，有 cp310 aarch64 包）：
```bash
source /usr/local/Ascend/ascend-toolkit/set_env.sh
source /root/anaconda3/etc/profile.d/conda.sh && conda activate minimax_h3

pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple \
    torch==2.9.1 torchvision==0.24.1 torch-npu==2.9.1
```

升级后**两个症状同时消失**，实测：
- text_encoder 跨 **7 张卡**分片，前向通过，输出 65 层 hidden_states `(1,6,5120)` bf16
- VAE Conv3D 解码通过：`(1,24,4,8,8)`→`(1,3,13,128,128)`、`(1,24,8,16,16)`→`(1,3,26,256,256)`

> 回滚命令见 `环境升级-回滚记录.md`。
> 注意根分区只剩 ~7GB，装包务必加 `--no-cache-dir`。
> `torchvision` 对 diffusers/transformers 只是 extra 依赖，但必须同步升到 0.24.1，
> 否则旧版 0.20.1 会因 ABI 不匹配在 import 时报错。

### 坑 2：`modelscope` 环境不可用

`modelscope` 环境的 diffusers 是 **0.36.0**，没有 `MiniMaxH3ModularPipeline`（0.40 才加入）。
`minimax_h3` 环境是 diffusers **0.40.0** + transformers 5.18.0，pipeline 已内置。

```bash
conda activate minimax_h3      # ✅ 正确
conda activate modelscope      # ❌ 没有 MiniMax H3 支持
```

另外**任何** conda 环境用 NPU 前都必须先 source CANN 环境，否则报
`ImportError: libhccl.so: cannot open shared object file`：
```bash
source /usr/local/Ascend/ascend-toolkit/set_env.sh
```

### 坑 3：不设离线模式会卡死在联网重试

`model_index.json` 里各组件的 `pretrained_model_name_or_path` 写的是 HF repo id
`MiniMaxAI/MiniMax-H3`，服务器无外网时会反复重试到超时：
```
'[Errno 101] Network is unreachable' thrown while requesting HEAD
https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/main/processor/processor_config.json
Retrying in 1s [Retry 1/5]. ...
```

**解决**：两处配合。
1. 环境变量：`HF_HUB_OFFLINE=1`、`TRANSFORMERS_OFFLINE=1`
2. 加载时用本地绝对路径覆盖 repo id：
```python
pipe.load_components(
    pretrained_model_name_or_path="/home/models/MiniMax-H3",   # ← 覆盖 index 里的 repo id
    dtype=torch.bfloat16,
)
```

> 模型目录里已有的 `modular_model_index_local.json` 是同样思路，但 diffusers 的
> `config_name` 硬编码为 `modular_model_index.json`，**不会自动读取** `_local` 那个文件。
> 用上面的 `pretrained_model_name_or_path=` 覆盖更简单，无需改动模型目录。

### 坑 4：`load_components(workflow=...)` 会抛 NotImplementedError

```
NotImplementedError: workflows is not supported because _workflow_map
is not set for SequentialPipelineBlocks
```

原因：`from_pretrained(workflow="t2va")` 已把 blocks **裁剪**成通用
`SequentialPipelineBlocks`，其 `_workflow_map` 丢失；此时再给 `load_components()`
传 `workflow=` 就会崩。

**解决**：`workflow=` 只在 `from_pretrained()` 传一次。裁剪后 `_component_specs`
已正确区分分区，`load_components()` 省略该参数即可：

| workflow | 加载的 transformer 分区 |
|---|---|
| `t2va` / `fl2va` | `transformer/`（62GB） |
| `ref2va` | `transformer_ref/`（62GB） |

不传 `workflow=` 给 `from_pretrained` 会**同时加载两个 62GB 分区**，白占内存和时间。

### 坑 5：`device_map="balanced"` 会让输出头与索引张量跨卡冲突

50 步去噪循环能全部跑完，最后死在 transformer forward 的收尾：

```
transformer_minimax_h3.py:659
  video_output = self.proj_out(hidden_states).index_select(1, video_indices)
RuntimeError: Expected all tensors to be on the same device, but got
  index is on npu:0, different from other tensors on npu:7
```

**原因**：`balanced` 按声明顺序分配设备，`norm_out` / `proj_out` / `audio_proj_out`
必然落在**最后一张卡**（npu:7）。而 `video_indices` / `audio_indices` 是 forward 的
**输入参数**，被 accelerate 的 hook 搬到了**首卡** npu:0。hook 只负责搬"模块的输入"，
不会搬 forward 内部的局部张量 —— 于是 `index_select` 的两个操作数分别在两张卡上。

注意这些索引在 forward **开头**也要用（`index_copy`），所以**不能把它们搬走**，
只能把输出头搬到 npu:0 与索引同卡。

**解决**：加载后把三个输出头搬到 npu:0，并且**必须递归修正子模块的 hook**：

```python
def move_output_heads_to(transformer, dev_idx=0):
    dev = torch.device(f"npu:{dev_idx}")
    for name in ("norm_out", "proj_out", "audio_proj_out"):
        mod = getattr(transformer, name, None)
        if mod is None:
            continue
        mod.to(dev)
        for sub in mod.modules():          # ← 递归, 含 norm_out.linear 等子模块
            hook = getattr(sub, "_hf_hook", None)
            if hook is not None and hasattr(hook, "execution_device"):
                hook.execution_device = dev
```

两个容易漏的点：

1. **只 `mod.to(dev)` 不改 hook 无效** —— `AlignDevicesHook.pre_forward` 会按
   `hook.execution_device` 把输入又搬回原卡。
2. **只改顶层 hook 不够** —— `norm_out.linear` 等子模块各自也挂了 hook。只改顶层会报：
   ```
   F.linear(input, self.weight, self.bias)
   RuntimeError: mat1 is on npu:7, different from other tensors on npu:0
   ```
   实测 `norm_out` 有 3 个 hook、`proj_out` 和 `audio_proj_out` 各 1 个。

> 附带结论：**传 dict 形式的 `device_map` 在这条路上不可行**。
> `_determine_device_map` 对非字符串直接原样返回，之后 diffusers 只按
> `_determine_param_device` 逐个放置 checkpoint 里的参数，既不真正 dispatch、
> 也不设置 `hf_device_map`。实测权重全留 CPU（加载"0 秒完成"），
> 后续手动搬运会把 60GB 全塞进 npu:0 直接 OOM。
> **必须用字符串 `device_map="balanced"`**，再做上面的事后调整。

### 坑 6：non-persistent buffer 不会跟着 device_map 上卡

```
transformer_minimax_h3.py:622  rotary_emb = self.rope(position_ids)
transformer_minimax_h3.py:96   freqs = position_ids.unsqueeze(-1) * self.inv_freq.view(1,1,-1)
RuntimeError: Expected all tensors to be on the same device. Expected NPU tensor...
```

**原因**：`MiniMaxH3RotaryEmbedding` 里
```python
self.register_buffer("inv_freq", inv_freq, persistent=False)
```
`persistent=False` 意味着它**不在 state_dict / checkpoint 里**。diffusers 是按
checkpoint 的参数名逐个 `_determine_param_device` 放置的，因此这类 buffer 会被漏掉、
留在 CPU。

**解决**：加载后按 `hf_device_map` 把游离 buffer 归位（脚本里的 `fix_stranded_tensors`）：

```python
for mod_name, mod in model.named_modules():
    dev_idx = _resolve_dev_idx(mod_name, model.hf_device_map, 0)   # 最长前缀匹配
    for bname, buf in list(mod._buffers.items()):
        if buf is not None and buf.device.type in ("cpu", "meta"):
            mod._buffers[bname] = buf.to(torch.device(f"npu:{dev_idx}"))
```

> ⚠️ 这个函数**只搬 buffer，不要顺手搬 parameter**。参数已由 accelerate dispatch
> 正确放置，再整体搬一遍会把 CPU 上的分片全塞进一张卡，实测直接把 npu:0 顶到
> 60.35GB 触发 OOM。（默认参数 `include_params=False` 就是为此。）

---

## 三、显存布局与设备模式

### shard 模式（默认，推荐）

`text_encoder`(63G)、`transformer`(62G)、`vae`(9.8G) 单个都放不下 64GB 单卡，
用 accelerate `device_map="balanced"` 跨卡分片：

```python
pipe.load_components(names=["text_encoder"],
                     pretrained_model_name_or_path=MODEL_ROOT,
                     dtype=torch.bfloat16,
                     device_map="balanced")
```

实测分布（8 卡，合计 **134.1GB**，加载耗时 ~28s）：
```
npu:0  25.89 GB     npu:4  17.50 GB
npu:1  17.50 GB     npu:5  17.50 GB
npu:2  17.50 GB     npu:6  17.13 GB
npu:3  17.50 GB     npu:7   3.61 GB
```

npu:0 偏高是因为它额外承载了 `vae`(9.8G) + `audio_vae`(0.6G) + 三个输出头。
每卡余量 38~60GB，足够放推理激活。

实测速度：**~28s/步**（768p、5 秒、124 帧、8 卡分片）。
4 步全流程 140s；**50 步全流程 1442s（约 24 分钟）**，含加载/文本编码/去噪/VAE 解码/导出。

**注意两点**：
- `max_memory={0: "50GiB", ...}` 的整数键在 NPU 上会被 accelerate **忽略**，实际按全部
  可见设备均分。要限定用哪几张卡只能用环境变量：
  `ASCEND_RT_VISIBLE_DEVICES=0,1,2,3 ./run_h3.sh ...`
- `audio_vae`（578MB）的类**没定义 `_no_split_modules`**，不支持 `device_map`，
  必须单独加载后 `.to("npu:0")`。这也是脚本要**分组加载**的原因 —— `device_map`
  一旦传下去会对所有组件生效：
  ```
  ValueError: AutoencoderKLMiniMaxH3Audio does not support `device_map='balanced'`.
  ```
  支持分片的类：`MiniMaxH3Transformer3DModel`、`AutoencoderKLMiniMaxH3`、
  `Qwen3VLForConditionalGeneration`；不支持的只有 `AutoencoderKLMiniMaxH3Audio`。

### offload 模式（备用）

权重全留 CPU RAM，大组件用 `accelerate.cpu_offload` 逐层流式送到**单张**卡，
峰值显存仅 ~21GB。已验证 text_encoder 前向可通。
但受 PCIe 带宽限制明显更慢，只在显存紧张时用。

> ⚠️ **不要把 VAE 解码放 CPU**：实测 768p/5s 的 `(1,24,32,48,48)` latent 在 CPU 上
> 解码耗时 **1648 秒（27.5 分钟）**，完全不可用。VAE 必须在 NPU 上跑。

---

## 四、使用方法

### 快速验证部署（只加载不推理，约 2 分钟）
```bash
cd /root/cys
./run_h3.sh --workflow t2va --load-only
```

### 文生视频 t2va
```bash
./run_h3.sh --workflow t2va \
    --prompt "A red fox running through a snowy forest at sunrise" \
    --duration 5 --steps 50 --seed 0
```

### 首帧图生视频 fl2va
```bash
./run_h3.sh --workflow fl2va --image /path/to/first.png --prompt "..." --duration 8
# 首尾帧同时给
./run_h3.sh --workflow fl2va --image first.png --last-image last.png --prompt "..."
```

### 参数

| 参数 | 说明 | 默认 |
|---|---|---|
| `--workflow` | `t2va` / `fl2va` / `ref2va` | `t2va` |
| `--prompt` | 文本提示词 | 示例 prompt |
| `--image` / `--last-image` | fl2va 首/尾帧 | — |
| `--duration` | 秒数，**范围 5~15** | 5 |
| `--steps` | 去噪步数 | 50 |
| `--height` / `--width` | 输出尺寸，32 的倍数 | 短边 768 自适应 |
| `--seed` | 随机种子 | 0 |
| `--device-mode` | `shard`（默认，快）/ `offload`（省显存，慢） | `shard` |
| `--npu` | offload 模式下的执行卡号 | 0 |
| `--output` | 输出路径 | `./h3_<workflow>.mp4` |
| `--load-only` | 只验证加载 | — |
| `--dtype` | `bf16` / `fp16` / `fp32` | `bf16` |

### 输出位置

根分区只剩 ~7GB，脚本默认把结果写到大盘：
```
/run/media/root/de00b5bd-371a-4e7a-89c5-627545c427f5/h3_outputs/
```
改路径：`H3_OUTDIR=/your/path ./run_h3.sh ...`

---

## 五、手动启动（不用包装脚本）

```bash
# 1. CANN 环境（torch_npu 必需）
source /usr/local/Ascend/ascend-toolkit/set_env.sh
# 2. conda 环境
source /root/anaconda3/etc/profile.d/conda.sh && conda activate minimax_h3
# 3. 离线 + 稳定性
export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 TORCHDYNAMO_DISABLE=1
export TASK_QUEUE_ENABLE=1
export PYTORCH_NPU_ALLOC_CONF="expandable_segments:True"
# 4. 验证 NPU
python -c "import torch, torch_npu; print(torch_npu.__version__, torch.npu.device_count())"
#    期望: 2.9.1 8
# 5. 跑推理
python /root/cys/start_minimax_h3.py --workflow t2va --prompt "..."
```

---

## 六、关键 API 速查

```python
from diffusers import MiniMaxH3ModularPipeline
import torch

pipe = MiniMaxH3ModularPipeline.from_pretrained(
    "/home/models/MiniMax-H3",
    trust_remote_code=True,
    workflow="t2va",           # 只在这里传一次
)

# 大组件跨卡分片; audio_vae 必须单独加载 (不支持 device_map)
pipe.load_components(
    names=["text_encoder", "transformer", "vae"],
    pretrained_model_name_or_path="/home/models/MiniMax-H3",   # 覆盖 HF repo id
    dtype=torch.bfloat16,
    device_map="balanced",
)
pipe.load_components(names=["audio_vae", "tokenizer", "processor",
                            "scheduler", "audio_scheduler"],
                     pretrained_model_name_or_path="/home/models/MiniMax-H3",
                     dtype=torch.bfloat16)
pipe.audio_vae.to(torch.device("npu:0"))

out = pipe(
    prompt="...",
    num_frames=120,            # 24fps × 秒数; 会被向上吸附到 17n+5
    num_inference_steps=50,
    generator=torch.Generator(device="cpu").manual_seed(0),
)

out.videos[0]        # 视频帧
out.audio            # 音轨张量 (1, 2, num_samples) 立体声
out.sampling_rate    # 32000
```

**三个 workflow 的输入要求**（来自 `MiniMaxH3Blocks._workflow_map`）：

| workflow | 必需输入 |
|---|---|
| `t2va` | `prompt` |
| `fl2va` | `prompt` + `image`，或 `prompt` + `last_image` |
| `ref2va` | `prompt` + `references` |

`num_frames` 会被向上吸附到 VAE 能解码的 `17n+5`（如 120 → 124），
换算后时长须落在 5~15 秒。

**视频导出**：PIL **不能写 mp4**（只能 GIF），要用
`diffusers.utils.export_to_video(frames, path, fps=24)`（底层 imageio）。
音轨用 `scipy.io.wavfile` 写 wav（环境里没装 `soundfile`），再用系统 `ffmpeg` 合成。

---

## 七、待补充 / 已知限制

1. **`ref2va` 已接线**（2026-10-03，h3studio）：`h3studio/engine.py` 的 `build_references()`
   支持图 ≤9 / 视频 ≤3 / 音频 ≤3 / 合计 ≤12，顺序即 `<Picture i>`/`<Video k>`/`<Audio j>` 编号。
   本机**没装 PyAV**，视频/音频素材自动走 ffmpeg 兜底解码（带 fps 与音轨采样率，不会静默变速）。
   参考类在 `diffusers.modular_pipelines.minimax_h3.references`：
   `MiniMaxH3ImageReference` / `MiniMaxH3VideoReference` / `MiniMaxH3AudioReference`。
   实测 `transformer` 与 `transformer_ref` 两个 62GB 分区**可共存**：8 卡合计 196.1GB，
   单卡最高 33.75GB（64GB 卡），切换 ref2va 无需重新加载；HBM 不够时引擎自动降级为 swap。

2. **单进程 8 卡是流水线式分片，不是张量并行**。官方推荐的生产部署是 SGLang
   （`--ulysses-degree 4` 序列并行），吞吐更高；本机未安装，需先确认其 Ascend 支持。

3. **AdaLN 裁剪可再省 26GB**：transformer 的 `adaln_out_features=96768`
   （= 6×3×5376），每层 `adaln_proj` 是 `Linear(2688, 96768)` ≈ 260M 参数，
   50 层共 **~13B / 26GB**。README 明确说这部分推理时不必加载（调制输出可预计算缓存，
   50 步全缓存仅 ~483MB）。裁掉后 transformer 降到 ~36GB，**单卡即可常驻**，
   可省掉跨卡搬运。目前未实现，是一个明确的优化点。

4. **`/root/cys/minimax_h3_patches/` 未使用**：经 diff 比对与 diffusers 0.40 内置源码
   **完全一致**（0 行差异），只是源码副本，无需引入。

5. **H3-Context-IR 和 H3-Regenerate-2K 未开源**：本地只能出 768p。2K 需调 MiniMax
   官方 API（要外网 + token），见模型目录 `scripts/readme/full-2k-*.sh`。
   本地部署建议按 README 的 "Prompting Guidance" 自建 prompt 增强。

6. **根分区空间紧张**（69G 已用 58G，剩 ~7GB）。pip 装包加 `--no-cache-dir`，
   视频输出写到大盘。

7. **⚠️ 本仓库权重的 VAE 解码输出是【负片】—— 最重要的色彩坑**

   现象：直出画面"像胶片底片"——皮肤青蓝、仙山惨白、天空墨黑。此前被误判为
   "过曝 / 白平衡 / 配色"问题，反复调色都治标不治本。

   验证：对输出帧做 `255 - x` 反相后得到**完全正确**的画面（暖肤、墨绿仙山、
   晨曦暖天、粉桃花）。即：输出 = 正确画面的负片。

   为什么色条往返测试没发现：encode 与 decode **带同一个反相**，往返互相抵消
   （红→红、蓝→蓝）；而生成路径只走 decode，反相因此暴露。这是往返测试的盲区。

   可能源头：本地顶层 diffusers 格式权重由 `convert_minimax_h3_weights.py` 自行转换
   （`FL2VA/video_vae/` 内是原始实现的 .py 源码，非 diffusers 格式，无法直接替换验证）。
   无论源头在哪，**negate 是确定有效的修正**。

   修正（老脚本固化进 `--fix-exposure`）：
   ```
   negate, eq=brightness=0.06:contrast=1.18:saturation=1.25:gamma=1.09
   ```
   先反相回正片，再做轻度观感调色。

   **⚠️ 2026-10-04 重大补充：负片不是权重的固定属性，极性随运行环境翻转！**
   对照实测：旧链路（`run_h3.sh`，cwd=OUTDIR，算子缓存在 `OUTDIR/kernel_meta`）的 raw
   是负片（亮度均值 ~64）；h3studio 服务（cwd=`OUTDIR/.studio`，独立算子缓存）的 raw
   是**正片**（亮度 ~120-130，暖调 R-B=+24）。同一份权重、同一条 diffusers 路径，
   唯一系统差异是 Ascend 算子编译缓存目录 —— 高度怀疑某个缓存里的 Conv3d kernel
   带了符号/格式差异（与坑 1 的 Conv3d 算子敏感性同源）。
   因此 h3studio **不再写死 negate**：`postprocess.decide_negate()` 按直出亮度均值
   自动判定（<95 判为负片），并可用 `--negate yes/no`、UI 下拉、`H3_NEGATE`/`H3_NEGATE_LUM`
   覆盖；判定结果写入任务日志。老脚本 `run_h3.sh/--fix-exposure` 在其自身缓存环境下
   仍然正确，不要混用两套环境的结论。

   反相后还会暴露一类**通道削顶伪影**：负片里被削到 0 的通道，反相后变成
   高饱和纯蓝/品红斑块（狐狸身上、剑鞘边缘）。信息已丢失，只能修复：
   `fix_artifacts.py` 用判据 `B>60 且 B-max(R,G)>30 且 B/max(R,G)>1.8`（比值判据可
   区分伪影与天然蓝影）+ 连通块≤20000px + 最近邻填充 + 高斯羽化，三遍迭代。
   实测每帧修复约 0.5% 像素，雪影/林影/青色绦带均不误伤。

   附带发现（次要）：VAE 对饱和色有固有洗白 —— 色条实测 青(0,1,1)→(0.43,0.91,0.97)、
   纯白→(0.84,0.92,0.93)、直出整体偏冷白 (RGB 均值 R-B ≈ -11)。
   上面的 saturation=1.25 即为此补偿；`test_vae_colorbars.py` 可复现。

   **⚠️ 2026-10-03 追加：伪影判据必须带"削顶物理特征"闸。**
   上面 `fix_artifacts.py` 的纯颜色判据在水墨淡彩内容上表现很好（~0.5% px），但在
   **自然内容**（雪地 + 深蓝天空）上会过触发：实测一段 5 秒 768p 片子被填掉 **12.6%**
   像素，天空留下多边形最近邻填充痕迹 —— 因为深蓝天空/树影同样满足
   `B>60 且 B-max(R,G)>30 且比值>1.8`。
   h3studio 的加固版（`h3studio/postprocess.py: artifact_color_mask` + 两道闸）：
   颜色判据 ∧（min 通道 <45 ∧ max 通道 >190）∧ 连通块 ≤20000px，
   晕边只清伪影邻域，毛边加局部离群闸。7 组合成用例：自然蓝 0 误伤、伪影/毛边全命中。
   老脚本 `fix_artifacts.py` 保持原样（它的判据对淡彩内容仍是对的），新内容请用 h3studio。

---

## 八、文件清单

### h3studio（2026-10-03 新增：常驻服务 + Web UI + 工作流）

| 文件 | 说明 |
|---|---|
| `h3.sh` | **一键入口**：配 CANN/conda/离线 → 起常驻服务（setsid，SSH 断开不死）→ 转发 CLI |
| `h3studio/engine.py` | 常驻推理引擎：**不传 workflow= 建单个 pipeline**，t2va/fl2va/ref2va 运行时自动选分支；分区按需加载可共存；进度钩子（patch 去噪循环的 progress_bar）；两步之间可取消 |
| `h3studio/service.py` | 纯标准库 HTTP 服务：任务队列（串行 FIFO）+ REST + 文件/缩略图（支持 Range）+ Web UI |
| `h3studio/cli.py` | 命令行客户端：t2v/i2v/fl2v/l2v/ref/batch/jobs/ls/post/assemble/check/doctor |
| `h3studio/postprocess.py` | 出片后处理链：negate → 伪影修复 → 调色 → 叠字 → 合成音轨 → bt709 标签；另有拼接/缩略图 |
| `h3studio/promptkit.py` | 官方 prompt 规则的实现：模板、指令行、运镜表、**体检**（`/api/check`） |
| `h3studio/h3meta.py` / `h3common.py` | 模型常量（17n+5 网格/画布/上限）与路径、任务存储 |
| `h3studio/selftest.py` | 引擎自检：真加载模型最小代价跑通三条分支（实测 5/5，约 2 分钟） |
| `h3studio/test_api.py` | 服务 API 干跑联调（21 个用例全过） |
| `h3studio/webui/index.html` | Web UI（单文件、无 CDN）：五种输入模式 + 结构化 prompt + 实时进度 + 成片库 |
| `MiniMax-H3-工作流手册.md` | **使用手册**：一键启动、五种工作流、prompt 速查、耗时/显存实测、后处理链、运维、API、排障 |

### 原有文件（保持不变，仍可用）

| 文件 | 说明 |
|---|---|
| `start_minimax_h3.py` | 主推理脚本（加载 + 分卡 + 推理 + 导出） |
| `run_h3.sh` | 环境包装脚本（CANN + conda + 离线 + 前置检查） |
| `verify_after_upgrade.py` | 升级后验证两个阻塞点的脚本 |
| `test_conv3d.py` | Conv3D 各变体体检 |
| `test_vae_decode.py` | VAE 解码隔离测试 |
| `test_vae_cpu.py` | VAE CPU 解码可行性测试（结论：太慢，不可用） |
| `test_vae_roundtrip.py` | 同一 latent 的 CPU/NPU 解码对比（结论：数值一致，NPU 正确） |
| `test_vae_colorbars.py` | 色条往返测试（结论：无通道置换；但查不出对称反相） |
| `fix_artifacts.py` | 修复负片反相后的通道削顶伪影（纯蓝/品红斑块，最近邻填充+羽化） |
| `overlay/add_overlay.sh` | 叠片头标题+对白字幕（宋体；调色归生成端管） |
| `overlay/title.txt` / `subtitle.txt` | 片头与字幕文本 |
| `prompts/xianjian_caoyongsheng.txt` | 仙剑题材 prompt（三字段结构+中文对白） |
| `环境升级-回滚记录.md` | torch/torch_npu 版本变更与回滚命令 |
| `h3_logs/` | 各阶段日志 |
| `h3_logs/frames/` | 抽帧与对比图（含颜色验证） |

---

## 附：关于 DSH 沙箱里的 `/dev/davinci*`

排查初期发现沙箱内 `torch.npu.is_available()` 返回 `False`、`/dev` 只有 14 个基础节点。
原因是**沙箱给 `/dev` 叠了一层空 tmpfs**，遮住了真实设备：

```
devtmpfs on /dev type devtmpfs (ro,...)     ← 真实 /dev（含 davinci0-7）
tmpfs    on /dev type tmpfs   (rw,...)     ← 沙箱叠加的空 tmpfs，遮蔽真实设备
```

**宿主机本身完全正常**：`/proc/1/root/dev/davinci*` 可见全部 8 个节点，
`npu-smi info` 在宿主机 shell 里正常输出。

所以：
- **在你自己的 shell 里直接跑 `./run_h3.sh` 即可**，不存在设备问题。
- 只有从 DSH 沙箱内部调用时才需要 `nsenter --mount=/proc/1/ns/mnt -- <命令>`
  进入宿主机挂载命名空间。本文所有验证都是用这个方式跑的。
- 沙箱对 `/root/anaconda3`、`/root/.cache` 也是只读的，pip 装包同样需要 nsenter。
