#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
webrtc_node/server.py — 实时管线的 WebRTC 服务端（节点0-b 回环 / 节点1 真实管线）。

🔴 为什么有 /deploy 热更新端点：本项目 SSH 全线不通，而每改一行代码重开一台机器
   要等 5 分钟拉镜像 + 2 分钟装依赖。这个端点就是 SSH 的替代——本机把代码打包
   POST 上来，落盘后进程自杀，由 onstart 里的 `while true` 循环秒级重启。

模式：
  loopback  —— 节点0-b：收帧 → 计时 → 原样回传。测 WebRTC 能否连通、端到端帧率、
               解码/编码各吃多少（29.4fps 那个基线**不含**编解码，这是真实瓶颈风险）
  pipeline  —— 节点1：收帧 → 换脸(ROI回贴) + RVM抠像 + 合成离线背景 → 回传
"""
import argparse
import asyncio
import base64
import concurrent.futures
import contextvars
import fractions
import json
import lzma
import os
import platform
import re
import sys
import threading
import time
import traceback
from collections import deque

from aiohttp import web
from aiortc import (MediaStreamTrack, RTCConfiguration, RTCIceServer,
                    RTCPeerConnection, RTCSessionDescription)
from av import VideoFrame

HERE = os.path.dirname(os.path.abspath(__file__))
TOKEN = os.environ.get("NODE_TOKEN", "dev")
MODE = os.environ.get("NODE_MODE", "loopback")
PCS = set()
LOG = deque(maxlen=400)

# ---------------------------------------------------------------- 远程协助旁观流（CLAUDE.md §15.11）
# 管理员在门户后台点「远程协助」→ 同一份 index.html 以协助模式打开 → 对本节点发 /watch/offer，
# 节点把正在处理的会话每帧的「输入原图 + 换脸结果」再推一份给管理员（只发不收）。
# 🔴 与主连接（PCS）完全隔离：/offer 会把 PCS 里所有连接清场（固定 ICE 端口只能一个连接占），
#    旁观连接放进 WATCHERS、走自己的端点、永不触发 on_track（否则 ProcTrack.__init__ 会 reset 共享管线）。
WATCHERS = set()
WATCH_MAX = 2                          # 同时最多两个旁观者（第二个绑不到 8082 会回落随机端口，靠 TURN）
TUNE_REV = 0                           # /tune 每次自增；协助页与用户页用它做参数双向同步（谁改了另一端刷新控件）
WATCH_CTX = contextvars.ContextVar("rtfs_watch", default=False)   # /watch/offer 期间为 True → ICE 不抢主连接的固定端口


class _Tap:
    """最新帧 tap：ProcTrack.recv() 每处理完一帧就把输入/输出各留一个引用（只在有旁观者时才做，无旁观者零开销）。
    user 拍板（2026-09-07）：旁观按被控端的实际参数——不降分辨率、不限帧率，对方处理一帧这里就有一帧。"""

    def __init__(self):
        self.inp = None
        self.out = None
        self.seq = 0
        self.ts = 0.0
        self._ev = asyncio.Event()

    def push(self, inp, out):
        self.inp, self.out = inp, out
        self.seq += 1
        self.ts = time.time()
        self._ev.set()

    async def wait(self, seen):
        """等到 seq 超过 seen。多个旁观轨道共用一个 Event：谁清了谁再等，push 会唤醒全部等待者。"""
        while self.seq == seen:
            self._ev.clear()
            await self._ev.wait()


TAP = _Tap()


def log(*a):
    s = f"[{time.strftime('%H:%M:%S')}] " + " ".join(str(x) for x in a)
    LOG.append(s)
    print(s, flush=True)


# ---------------------------------------------------------------- 统计
class Stats:
    """逐环节计时。🔴 别把各环节单独 benchmark 的数字相加当管线性能——
    这条在第 1–8 轮吃过亏（相加 41ms vs 整合 78ms），所以这里只统计真实在线数据。"""

    def __init__(self, n=120):
        self.recv_ts = deque(maxlen=n)      # 收到帧的时刻 → 上行帧率
        self.proc_ms = deque(maxlen=n)      # 我们的处理耗时
        self.gap_ms = deque(maxlen=n)       # 两帧之间的总间隔（含 aiortc 编解码与排队）
        self.frames = 0
        self.dropped = 0                    # 丢帧数（积压帧被丢弃，实时管线正常现象）
        self.last_error = None            # 🔴 管线异常要暴露到前端，否则只表现为"没效果"
        self.t0 = time.time()
        self.last = None
        self.stage = {}                     # 管线各阶段耗时

    def tick(self, proc_ms, stages=None):
        now = time.time()
        self.frames += 1
        self.recv_ts.append(now)
        self.proc_ms.append(proc_ms)
        if self.last is not None:
            self.gap_ms.append((now - self.last) * 1000)
        self.last = now
        if stages:
            for k, v in stages.items():
                self.stage.setdefault(k, deque(maxlen=120)).append(v)

    def snapshot(self):
        def avg(d):
            return round(sum(d) / len(d), 2) if d else None

        fps = None
        if len(self.recv_ts) > 1:
            span = self.recv_ts[-1] - self.recv_ts[0]
            fps = round((len(self.recv_ts) - 1) / span, 2) if span > 0 else None
        return {
            "mode": MODE, "frames": self.frames, "dropped": self.dropped,
            "uplink_fps": fps,
            "proc_ms_avg": avg(self.proc_ms),
            "proc_ms_max": round(max(self.proc_ms), 2) if self.proc_ms else None,
            "frame_gap_ms_avg": avg(self.gap_ms),
            "stages_ms": {k: avg(v) for k, v in self.stage.items()},
            "last_error": self.last_error,
            "uptime_s": round(time.time() - self.t0, 1),
        }


STATS = Stats()

# ---------------------------------------------------------------- ICE 可达性修正
# 🔴 这是让浏览器能连上 GPU 实例的关键，实测踩出来的：
#    ① 容器在 docker NAT 后，aiortc 的 host candidate 报的是 172.17.0.2:随机端口，外部无意义；
#    ② srflx candidate 报的是**出站**公网 IP（实测 180.189.55.38），而实例的**入站**映射
#       IP 是另一个（180.189.55.43）——宣告的地址根本不是能被连上的地址，打洞必然失败；
#    ③ 但 vast 的端口映射本身是通的（探针实测 TCP/UDP 直连都可达）。
#    所以正解不是上 TURN 中继，而是：把 ICE socket 绑到**已映射的容器内固定端口**，
#    再往 SDP 里注入一条指向「入站公网 IP + 外部映射端口」的 candidate。
#    媒体仍是浏览器↔GPU 直连，不经任何第三方，符合架构铁律。
CFG_PATH = os.path.join(HERE, "node_config.json")
CFG = {}
if os.path.exists(CFG_PATH):
    try:
        CFG = json.load(open(CFG_PATH, encoding="utf-8"))
    except Exception:
        CFG = {}
ICE_PORT = int(CFG.get("ice_port") or 8081)          # 容器内固定端口（必须是已 -p 映射的那个）
PUB_HOST = CFG.get("public_host")                    # 实例入站公网 IP
PUB_UDP = CFG.get("ext_udp")                         # 该端口对外的映射端口


def patch_aioice_fixed_port(port):
    """让 aioice 的 ICE socket 绑定固定端口（原实现写死 local_addr=(addr, 0) 随机端口）。
    只固定第一个成功绑上的 socket——rtcp-mux + BUNDLE 下只需要一个。"""
    import asyncio as _a
    loop_cls = _a.selector_events.BaseSelectorEventLoop
    if getattr(loop_cls, "_rtfs_patched", False):
        return
    orig = loop_cls.create_datagram_endpoint

    async def patched(self, factory, *args, local_addr=None, **kw):
        # 🔴 每次都尝试绑固定端口，不能用一次性开关：开关会让**第二个连接**退回随机端口，
        #    于是注入的 candidate 指向的端口根本没人监听 → 第二次开始永远连不上。
        #    旧连接关闭后端口自然释放，这里再绑就又能用（配合下面的单连接策略）。
        # 🔴 旁观连接（/watch/offer，WATCH_CTX 为 True）绑 port+1（8082），绝不碰主连接的 8081：
        #    否则用户 stop 期间管理员先连上会占走 8081，用户再 start 时注入的 candidate 指向被占的端口 → 0 帧。
        #    contextvar 在 setLocalDescription → gather → create_datagram_endpoint 这条 await 链上可见
        #    （asyncio.gather 起的子任务复制当前 context）。8082 也被占（第二个旁观者）就回落随机端口，靠 TURN。
        if local_addr and local_addr[1] == 0:
            want = port + 1 if WATCH_CTX.get() else port
            try:
                r = await orig(self, factory, *args, local_addr=(local_addr[0], want), **kw)
                log(f"ICE socket 已绑定固定端口 {local_addr[0]}:{want}" + ("（旁观）" if want != port else ""))
                return r
            except OSError:
                pass                      # 端口占用（同一 PC 的第二个 socket）→ 回落随机
        return await orig(self, factory, *args, local_addr=local_addr, **kw)

    loop_cls.create_datagram_endpoint = patched
    loop_cls._rtfs_patched = True


def _ice_addr():
    """🔴 每次现读 node_config.json，不用启动时加载的 PUB_HOST/PUB_UDP。
    因为无重启切 pipeline（pushp）时新实例的 IP/端口是 push 时才写进 node_config 的，
    模块级 CFG 还是旧实例的地址——注入旧地址会导致浏览器媒体连不上（0 帧）。"""
    try:
        c = json.load(open(CFG_PATH, encoding="utf-8"))
        return c.get("public_host") or PUB_HOST, c.get("ext_udp") or PUB_UDP
    except Exception:
        return PUB_HOST, PUB_UDP


def _stun_list():
    """node_config.json 的 stun 列表（现读，与 _ice_addr 同理）；没配 = 空列表 = ICE 只收集 host candidate、零等待。"""
    try:
        s = json.load(open(CFG_PATH, encoding="utf-8")).get("stun") or []
        return [u for u in s if isinstance(u, str) and u.startswith("stun:")]
    except Exception:
        return []


def _ice_server_objs():
    """/offer 用的 RTCIceServer 列表（现读）：
    · node_config.json 的 ice_servers —— 门户会话下发（含 TURN 时限凭据），由 agent 在 session_start 时写入；
    · 旧的 stun 列表（Vast 直连模式，deploy_node 写）。
    都没有 = 空 = 只收集 host candidate、零等待（ai002 直连 Meshnet 模式）。"""
    out = []
    try:
        cfg = json.load(open(CFG_PATH, encoding="utf-8"))
    except Exception:
        cfg = {}
    for it in cfg.get("ice_servers") or []:
        if isinstance(it, dict) and it.get("urls"):
            urls = it["urls"] if isinstance(it["urls"], list) else [it["urls"]]
            out.append(RTCIceServer(urls=urls, username=it.get("username"), credential=it.get("credential")))
    for u in _stun_list():
        out.append(RTCIceServer(urls=u))
    return out


def patch_aioice_gather(timeout=1.5):
    """🔴 配了 STUN/TURN 之后 aioice 会对**每张网卡**发 STUN，169.254.*（APIPA）/ WSL / OpenVPN 那些永远没应答，
    收集就固定等满 5s（§14.5e 实测：两端各 5s，点开始要等 11s）。门户模式必须配 TURN 兜底，所以：
      ① 过滤链路本地地址（169.254.* / fe80:*）不参与收集；
      ② 每个组件的收集超时 5s → 1.5s（STUN 应答本来只要几十 ms）。
    只改 aioice 的入口函数，不动其内部实现。"""
    try:
        import aioice.ice as I
    except Exception as e:
        log("aioice 补丁跳过:", e)
        return
    if getattr(I, "_rtfs_gather_patched", False):
        return
    orig_addrs = I.get_host_addresses

    def filtered(use_ipv4, use_ipv6):
        addrs = orig_addrs(use_ipv4, use_ipv6)
        keep = [a for a in addrs if not a.startswith("169.254.") and not a.lower().startswith("fe80")]
        return keep or addrs

    I.get_host_addresses = filtered
    g = getattr(I.Connection, "get_component_candidates", None)
    if g is not None:
        async def g2(self, component, addresses, timeout=timeout):
            return await g(self, component, addresses, timeout=timeout)
        I.Connection.get_component_candidates = g2
    I._rtfs_gather_patched = True
    log("aioice 收集补丁已生效（过滤 169.254.* / 超时 %.1fs）" % timeout)


def inject_candidate(sdp):
    """往 answer 里加一条指向「入站公网 IP + 外部映射端口」的 host candidate。
    不删原有 candidate——浏览器会并行试所有的，能通哪条走哪条。"""
    pub_host, pub_udp = _ice_addr()
    if not (pub_host and pub_udp):
        return sdp
    lines, out, added = sdp.split("\n"), [], False
    for l in lines:
        out.append(l)
        if l.startswith("a=candidate:") and not added:
            f = l.strip().split()
            f[0] = "a=candidate:rtfsmapped"          # foundation 随便给个不重名的
            f[4], f[5] = pub_host, str(pub_udp)      # IP、端口换成真正能连上的
            out.append(" ".join(f))
            added = True
    if added:
        log(f"已注入可达 candidate {pub_host}:{pub_udp}")
    return "\n".join(out)

# ---------------------------------------------------------------- 处理器
_PIPELINE = None


def get_pipeline():
    """节点1 的真实管线。懒加载，loopback 模式下完全不碰模型。"""
    global _PIPELINE
    if _PIPELINE is None:
        from pipeline_rt import RealtimePipeline          # 同目录，随代码包一起上传
        _PIPELINE = RealtimePipeline()
    return _PIPELINE


class ProcTrack(MediaStreamTrack):
    kind = "video"

    def __init__(self, track):
        super().__init__()
        self.track = track
        STATS.last_error = None
        if MODE == "pipeline" and _PIPELINE is not None:
            _PIPELINE.reset()                 # 新连接必须清 RVM 循环状态（见 pipeline_rt.reset）
        # 🔴 丢帧（always-process-latest）：实时交互里延迟才是王。若处理慢于输入(30fps)，
        #    逐帧排队会让延迟无限累积（user 实测 5s 延迟就是这么来的）。后台读线程以输入全速
        #    消费浏览器来帧、只保留最新一帧，积压的直接丢弃 → 端到端延迟被钉死在「处理+网络」。
        self._latest = None
        self._seq = 0
        self._new = asyncio.Event()
        self._ended = False
        self._reader = asyncio.ensure_future(self._read_loop())

    async def _read_loop(self):
        while True:
            try:
                f = await self.track.recv()
            except Exception:
                self._ended = True
                self._new.set()
                return
            if self._latest is not None:
                STATS.dropped += 1        # 上一帧还没被处理就被新帧覆盖 = 丢了一帧
            self._latest = f
            self._seq += 1
            self._new.set()

    async def recv(self):
        # 等到有新帧，取"最新"的那一帧处理（中间积压的全丢），延迟不累积
        await self._new.wait()
        self._new.clear()
        if self._ended and self._latest is None:
            from aiortc.mediastreams import MediaStreamError
            raise MediaStreamError
        frame = self._latest
        self._latest = None               # 取走，标记已消费
        t0 = time.perf_counter()
        stages = None
        tap_in = tap_out = None                       # 远程协助旁观流取帧点（§15.11）：输入原图 / 换脸结果
        if MODE == "pipeline":
            try:
                img = frame.to_ndarray(format="bgr24")
                t_dec = (time.perf_counter() - t0) * 1000
                out, stages = get_pipeline().process(img)
                t_p = time.perf_counter()
                new = VideoFrame.from_ndarray(out, format="bgr24")
                new.pts, new.time_base = frame.pts, frame.time_base
                stages["decode"] = round(t_dec, 2)
                stages["to_frame"] = round((time.perf_counter() - t_p) * 1000, 2)
                frame = new
                tap_in, tap_out = img, out
            except Exception as e:                        # 单帧失败不能拖垮整条流
                STATS.last_error = f"{type(e).__name__}: {e}"
                log("处理异常:", type(e).__name__, e)
                log(traceback.format_exc()[-600:])
        else:
            # loopback：只测解码 → ndarray → 重新封帧的开销，即"不做任何处理"的地板
            img = frame.to_ndarray(format="bgr24")
            new = VideoFrame.from_ndarray(img, format="bgr24")
            new.pts, new.time_base = frame.pts, frame.time_base
            frame = new
            tap_in = tap_out = img
        STATS.tick((time.perf_counter() - t0) * 1000, stages)
        if WATCHERS and tap_in is not None:           # 只存引用不拷贝；旁观轨道封帧时 from_ndarray 自己拷贝
            TAP.push(tap_in, tap_out)
        return frame


class TapTrack(MediaStreamTrack):
    """旁观流轨道（§15.11）：逐帧转发 TAP 里的最新帧。which = "in"（对方摄像头原图）/ "out"（换脸结果）。
    对方每处理一帧这里就发一帧，分辨率与帧率都与对方一致；对方停止后 TAP 不再更新，每 1s 重发最后一帧维持连接
    （协助页按 stats.frames 不再增长显示「对方已停止」，不冒充实时）。"""
    kind = "video"

    def __init__(self, which):
        super().__init__()
        self.which = which
        self._seen = TAP.seq
        self._last = None
        self._t0 = None
        self.sent = 0

    async def recv(self):
        try:
            await asyncio.wait_for(TAP.wait(self._seen), timeout=1.0)
        except asyncio.TimeoutError:
            pass
        self._seen = TAP.seq
        img = TAP.inp if self.which == "in" else TAP.out
        if img is None:
            img = self._last
        if img is None:                               # 还没有任何帧（对方从未开始）：给一张黑图占位，让连接先建起来
            import numpy as np
            img = np.zeros((360, 640, 3), np.uint8)
        self._last = img
        f = VideoFrame.from_ndarray(img, format="bgr24")
        now = time.monotonic()
        if self._t0 is None:
            self._t0 = now
        f.pts = int((now - self._t0) * 90000)        # 按墙钟给 pts（重发同一帧 pts 也递增）
        f.time_base = fractions.Fraction(1, 90000)
        self.sent += 1
        return f


# ---------------------------------------------------------------- HTTP
async def index(request):
    # 禁缓存：网页版/桌面版都靠"服务端改了页面立刻生效"，浏览器启发式缓存会让用户看旧界面
    return web.FileResponse(os.path.join(HERE, "index.html"),
                            headers={"Cache-Control": "no-store, max-age=0", "Pragma": "no-cache"})


# ---------------------------------------------------------------- 桌面版下载
DIST = os.path.join(HERE, "dist")
# 安装包文件名固定，网页入口因此永远指向最新一次构建的产物
SETUP_NAME = "RealtimeFaceSwap-Setup.exe"

DOWNLOAD_PAGE = """<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>下载桌面版 · 实时换脸换背景</title><style>
:root{color-scheme:dark}
body{margin:0;background:#0d1117;color:#e6edf3;font:15px/1.75 -apple-system,"Segoe UI",system-ui,sans-serif}
.wrap{max-width:720px;margin:0 auto;padding:48px 24px 64px}
h1{font-size:26px;margin:0 0 8px}
h2{font-size:17px;margin:0 0 10px}
.sub{color:#8b949e;margin:0 0 28px}
.dl{display:inline-block;background:#238636;border:1px solid #2ea043;color:#fff;padding:14px 34px;
    border-radius:10px;font-size:17px;font-weight:600;text-decoration:none}
.dl.off{background:#21262d;border-color:#30363d;color:#8b949e;cursor:not-allowed}
.meta{color:#8b949e;font-size:13px;margin-top:10px}
ol{padding-left:0;list-style:none;counter-reset:s;margin:34px 0 0}
ol li{counter-increment:s;position:relative;padding:16px 0 16px 52px;border-top:1px solid #21262d}
ol li::before{content:counter(s);position:absolute;left:0;top:16px;width:32px;height:32px;border-radius:50%;
    background:#1f6feb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600}
li b{color:#fff}
.hl{color:#7ee787;font-weight:600}
.note{margin-top:30px;padding:14px 16px;background:#161b22;border:1px solid #30363d;border-radius:10px;
      color:#8b949e;font-size:13px}
/* 安全提示图解 */
.warn{margin-top:36px;padding:20px 22px;background:#1c1a10;border:1px solid #d29922;border-radius:12px}
.warn h2{color:#e3b341}
.warn p{color:#c9b884;margin:0 0 6px}
.shots{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin:20px 0 6px}
@media(max-width:640px){.shots{grid-template-columns:1fr}}
.shot{background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;font-size:12.5px}
.shot .cap{color:#8b949e;margin-bottom:10px;font-size:12px}
.mock{background:#22262c;border:1px solid #3d444d;border-radius:8px;padding:11px 12px;color:#e6edf3}
.mock .t{font-weight:600;margin-bottom:3px}
.mock .d{color:#9aa4b2;font-size:11.5px;line-height:1.5}
.mock.blue{background:#1b3a5c;border-color:#2d5c8f}
.act{margin-top:9px;display:flex;gap:7px;align-items:center;flex-wrap:wrap}
.btn{background:#30363d;border:1px solid #484f58;border-radius:5px;padding:3px 11px;font-size:11.5px}
.btn.go{background:#238636;border-color:#2ea043;color:#fff;font-weight:600}
.arrow{color:#e3b341;font-weight:700}
.hash{margin-top:16px;color:#8b949e;font-size:12px;word-break:break-all}
.hash code{color:#79c0ff;background:#161b22;padding:2px 6px;border-radius:4px}
</style></head><body><div class="wrap">
<h1>桌面版</h1>
<p class="sub">装上它，就能在 <b>Zoom、腾讯会议、飞书、Teams、Google Meet</b> 里直接用换脸后的画面开会。</p>
__BUTTON__

<div class="warn">
<h2>⚠️ 下载和安装会各弹一次安全提示，这是正常的</h2>
<p>因为这个安装包还没有购买微软的代码签名证书。<b>任何新发布的小众软件都会这样</b>——
系统不认识它，不代表它有问题。按下面两步点过去即可。</p>
<div class="shots">
  <div class="shot">
    <div class="cap">① 浏览器（Chrome / Edge）</div>
    <div class="mock">
      <div class="t">已阻止此文件 / 此文件可能有害</div>
      <div class="d">不常下载，可能会损害您的计算机</div>
      <div class="act"><span class="btn">删除</span><span class="arrow">→ 点</span>
        <span class="btn">⋮</span><span class="arrow">→</span><span class="btn go">保留</span></div>
    </div>
    <div class="d" style="margin-top:9px;color:#8b949e">Edge 里叫「···」→「保留」→「仍然保留」</div>
  </div>
  <div class="shot">
    <div class="cap">② 双击安装时（Windows）</div>
    <div class="mock blue">
      <div class="t">Windows 已保护你的电脑</div>
      <div class="d">Microsoft Defender SmartScreen 阻止了无法识别的应用</div>
      <div class="act"><span class="btn go">更多信息</span><span class="arrow">→ 再点</span>
        <span class="btn go">仍要运行</span></div>
    </div>
    <div class="d" style="margin-top:9px;color:#8b949e">先点蓝框里的「更多信息」，按钮才会出现</div>
  </div>
</div>
<div class="hash">要核对文件有没有被篡改，可以对比 SHA-256：<br><code>__SHA__</code></div>
</div>

<ol>
<li><b>双击安装</b><br>过程中系统会弹一次授权提示，点「是」即可（需要这一步才能把虚拟摄像头装进系统）。</li>
<li><b>打开桌面上的「实时换脸」图标，点「开始」</b><br>界面和这个网页是同一套，参数怎么调都一样。</li>
<li><b>在会议软件里选摄像头</b><br>以 Zoom 为例：<b>设置 → 视频 → 摄像头</b>，选择
    <span class="hl">AI 换脸摄像头</span>。其他软件在各自的视频设置里同理。</li>
</ol>
<div class="note">开会期间请让这个程序一直开着——它是画面的来源，关掉后会议里的画面就没了。<br>
支持 Windows 10 / 11 64 位。<br><br>
<b style="color:#e6edf3">装完怎么确认是最新版？</b><br>
打开安装目录（默认 <code style="color:#79c0ff">C:\\Program Files\\RealtimeFaceSwap</code>），
里面应该有 <code style="color:#79c0ff">MicrosoftEdgeWebview2Setup.exe</code>。
没有这个文件说明浏览器给了缓存的旧包，请强制刷新本页（Ctrl+F5）后重新下载。<br><br>
<b style="color:#e6edf3">出问题要看日志？</b><br>
日志不在安装目录，在用户目录下。开始菜单里有「查看日志」，双击即可；
也可以按 <b>Win+R</b> 输入 <code style="color:#79c0ff">%APPDATA%\\RealtimeFaceSwap</code> 回车。</div>
</div></body></html>"""


_SHA_CACHE = {}


def _setup_sha256(path):
    """算安装包的 SHA-256，按 (大小, mtime) 缓存——69MB 每次请求都算太浪费。"""
    st = os.stat(path)
    key = (st.st_size, int(st.st_mtime))
    if _SHA_CACHE.get("key") != key:
        import hashlib
        h = hashlib.sha256()
        with open(path, "rb") as f:
            for chunk in iter(lambda: f.read(1 << 20), b""):
                h.update(chunk)
        _SHA_CACHE.update(key=key, hex=h.hexdigest())
    return _SHA_CACHE["hex"]


def _version_meta():
    """读构建时写下的版本清单（版本号/时间/sha256）。没有就返回空 dict。"""
    try:
        with open(os.path.join(DIST, "version.json"), "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


SERVER_STARTED = time.time()


async def version(request):
    """给界面顶栏显示版本用：界面文件更新时间 + 服务端启动时间 + 桌面版最新版本（供桌面版比对提示更新）。
    user：「不显示版本我都不知道是不是最新版」。"""
    p = os.path.join(HERE, "index.html")
    ui = time.strftime("%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(p))) if os.path.exists(p) else "?"
    return web.json_response({
        "ui": ui,
        "server_started": time.strftime("%Y-%m-%d %H:%M", time.localtime(SERVER_STARTED)),
        "desktop": _version_meta(),
        "ice_stun": _stun_list(),          # 客户端（网页/桌面）按这个配 ICE；空=不配 STUN、零等待
    })


async def download(request):
    """桌面版下载页。安装包尚未构建时按钮置灰，不给死链。"""
    path = os.path.join(DIST, SETUP_NAME)
    sha = "（安装包尚未构建）"
    if os.path.exists(path):
        mb = os.path.getsize(path) / 1048576.0
        meta = _version_meta()
        try:
            sha = meta.get("sha256") or _setup_sha256(path)
        except Exception as e:
            sha = "（校验值计算失败：%s）" % e
        # 🔴 文件名固定，浏览器会拿缓存的旧包（实测有人因此装到旧版）。
        #    带上指纹参数，内容一变链接就变，缓存自然失效。
        ver = meta.get("version", "")
        stamp = (sha[:12] if isinstance(sha, str) else "") or "latest"
        label = ("v%s · %s · %.1f MB" % (ver, meta.get("built", ""), mb)) if ver else \
                ("%s · %.1f MB" % (SETUP_NAME, mb))
        btn = ('<a class="dl" href="/download/%s?v=%s">下载 Windows 安装包</a>'
               '<div class="meta">%s</div>' % (SETUP_NAME, stamp, label))
    else:
        btn = ('<span class="dl off">安装包正在准备中</span>'
               '<div class="meta">构建完成后这里会出现下载按钮</div>')
    html = DOWNLOAD_PAGE.replace("__BUTTON__", btn).replace("__SHA__", sha)
    return web.Response(text=html, content_type="text/html")


# ---------------------------------------------------------------- 人设（头像）
ASSETS_DIR = os.path.join(HERE, "assets")
PERSONA_EXT = (".jpg", ".jpeg", ".png", ".webp")
PERSONA_META = os.path.join(ASSETS_DIR, "personas.json")
# 人设图只用来提取身份向量，不需要高分辨率；统一压到这个尺寸，省空间也让列表加载快
PERSONA_MAX_SIDE = 1024


def _persona_labels():
    try:
        with open(PERSONA_META, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def _persona_list():
    """扫描 assets 下的人设图。文件名以 persona 开头的都算，标签取自 personas.json。"""
    labels = _persona_labels()
    out = []
    try:
        names = sorted(os.listdir(ASSETS_DIR))
    except OSError:
        return out
    for f in names:
        low = f.lower()
        if not low.startswith("persona") or not low.endswith(PERSONA_EXT):
            continue
        if low == "personas.json":
            continue
        out.append({"file": f, "type": "image",
                    "label": labels.get(f) or os.path.splitext(f)[0],
                    "url": "/assets/" + f})
    # DFM 专属人物模型（§11.28）：清单来自 dfm_swapper（内置 dfm_models.json + 用户添加 assets/dfm_user.json），
    # file 用 "dfm:<name>" 直接作 persona_name；url 是预览图（用户添加的模型首次加载后才有）。
    try:
        import dfm_swapper as _D
        for m in _D.list_models():
            out.append({"file": "dfm:" + m["name"], "type": "model", "label": m["label"],
                        "url": ("/assets/" + m["preview"]) if m.get("preview") else None,
                        "ready": m["ready"], "size_mb": m.get("size_mb"), "res": m.get("res"),
                        "author": m.get("author", ""), "builtin": m["builtin"], "note": m.get("note", "")})
    except Exception as e:
        log("DFM 模型清单读取失败:", type(e).__name__, e)
    return out


async def personas(request):
    cur = ""
    try:
        if MODE == "pipeline" and _PIPELINE is not None:
            cur = getattr(_PIPELINE, "persona_name", "") or ""
    except Exception:
        pass
    p = _PIPELINE
    st = int(getattr(p, "_dfm_state", 0) or 0) if p else 0
    return web.json_response({"items": _persona_list(), "current": cur,
                              "error": getattr(p, "persona_error", "") if p else "",
                              # DFM 模型加载状态（§11.28）：state 1 时 loading 是正在下载/加载的 "dfm:<name>"
                              "state": st, "progress": float(getattr(p, "_dfm_progress", 0) or 0) if p else 0,
                              "msg": getattr(p, "_dfm_msg", "") if p else "",
                              "loading": getattr(p, "_dfm_want", None) if (p and st == 1) else None})


async def upload_persona(request):
    """上传一张人设照片。先验有没有脸，再存——不然用户要等到切换时才知道这张图不能用。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    try:
        reader = await request.multipart()
        field = await reader.next()
        raw = bytearray()
        while True:
            chunk = await field.read_chunk(65536)
            if not chunk:
                break
            raw.extend(chunk)
            if len(raw) > 20 * 1024 * 1024:
                return web.json_response({"error": "图片太大了（上限 20MB）"}, status=400)
        label = (request.query.get("label") or field.filename or "我的人设").strip()
    except Exception as e:
        return web.json_response({"error": "读取上传内容失败: %s" % e}, status=400)
    if not raw:
        return web.json_response({"error": "没有收到图片"}, status=400)

    import cv2
    import numpy as np
    img = cv2.imdecode(np.frombuffer(bytes(raw), np.uint8), cv2.IMREAD_COLOR)
    if img is None:
        return web.json_response({"error": "这个文件不是有效的图片"}, status=400)

    # 先验人脸：管线没加载时（loopback 模式）跳过，但要告诉调用方没验过
    verified = False
    try:
        p = get_pipeline()
        faces = p.app.get(img)
        if not faces:
            return web.json_response(
                {"error": "这张照片里没有检测到人脸。请换一张正脸、光线清楚、脸部较大的照片。"},
                status=400)
        verified = True
    except Exception as e:
        log("上传人设：跳过人脸校验（管线未就绪）:", e)

    h, w = img.shape[:2]
    scale = min(1.0, float(PERSONA_MAX_SIDE) / max(h, w))
    if scale < 1.0:
        img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
    name = "persona_%d.jpg" % int(time.time() * 1000)
    ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 92])
    if not ok:
        return web.json_response({"error": "图片编码失败"}, status=500)
    # 🔴 用字节写而不是 cv2.imwrite：路径含非 ASCII 时 imwrite 会静默失败（CLAUDE.md §5）
    with open(os.path.join(ASSETS_DIR, name), "wb") as f:
        f.write(buf.tobytes())

    labels = _persona_labels()
    labels[name] = label[:40]
    try:
        tmp = PERSONA_META + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(labels, f, ensure_ascii=False, indent=1)
        os.replace(tmp, PERSONA_META)          # 原子写，避免断电留半截文件
    except Exception as e:
        log("写人设标签失败:", e)
    log("新增人设:", name, label, "已验人脸" if verified else "(未验)")
    return web.json_response({"ok": True, "file": name, "verified": verified,
                              "items": _persona_list()})



async def add_dfm(request):
    """添加 DFM 专属人物模型（§11.28）。两种方式：
      · JSON {url, label}：只登记下载地址，选中时节点自己下载（门户模式唯一可行的路——1GB 文件穿隧道不现实）；
      · multipart 上传 .dfm 文件：直连/桌面版用，流式落盘不进内存（client_max_size 只管 read()/post()，不管 multipart 流）。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    import dfm_swapper as _D
    ctype = request.headers.get("Content-Type", "")
    try:
        if ctype.startswith("multipart/"):
            reader = await request.multipart()
            field = await reader.next()
            fname = os.path.basename(field.filename or "")
            if not fname.lower().endswith(".dfm"):
                return web.json_response({"error": "只接受 .dfm 文件"}, status=400)
            name = _D.safe_name(fname)
            label = (request.query.get("label") or fname[:-4]).strip()[:40] or name
            path = _D.model_path(name)
            tmp = path + ".part"
            os.makedirs(os.path.dirname(path), exist_ok=True)
            size = 0
            with open(tmp, "wb") as f:
                while True:
                    chunk = await field.read_chunk(1 << 20)
                    if not chunk:
                        break
                    f.write(chunk)
                    size += len(chunk)
            if size < 10 * 1024 * 1024:
                os.remove(tmp)
                return web.json_response({"error": "文件太小（%d 字节），不是有效的 .dfm" % size}, status=400)
            os.replace(tmp, path)
            _D.add_user_model(name, label, url=None, size_mb=round(size / 1e6))
            log("新增 DFM 模型（上传）:", name, "%.0fMB" % (size / 1e6))
        else:
            body = await request.json()
            url = str(body.get("url", "")).strip()
            if not (url.startswith("http://") or url.startswith("https://")) or ".dfm" not in url.lower():
                return web.json_response({"error": "下载地址必须是 http(s) 直链且指向 .dfm 文件"}, status=400)
            base = os.path.basename(url.split("?")[0])
            name = _D.safe_name(base)
            default_label = base[:-4] if base.lower().endswith(".dfm") else base
            label = str(body.get("label") or default_label).strip()[:40] or name
            _D.add_user_model(name, label, url=url)
            log("新增 DFM 模型（地址）:", name, url)
    except Exception as e:
        return web.json_response({"error": "添加失败: %s" % e}, status=500)
    return web.json_response({"ok": True, "file": "dfm:" + name, "items": _persona_list()})


# ---------------------------------------------------------------- 参数预设
# 🔴 预设存在服务端而不是浏览器 localStorage：桌面版的本地服务端口每次启动都可能变（origin 变了
#    localStorage 就是另一份），网页版换浏览器/清缓存也会丢。单用户系统，一份 presets.json 两边共用。
PRESETS_PATH = os.path.join(HERE, "presets.json")
_PRESET_LOCK = threading.Lock()


def _presets_load():
    try:
        with open(PRESETS_PATH, "r", encoding="utf-8") as f:
            d = json.load(f)
        if not isinstance(d, dict) or not isinstance(d.get("presets"), list):
            raise ValueError("presets.json 结构不对")
        return d
    except FileNotFoundError:
        return {"presets": [], "current": None}


def _presets_save(d):
    tmp = PRESETS_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(d, f, ensure_ascii=False, indent=1)
    os.replace(tmp, PRESETS_PATH)


async def presets_get(request):
    return web.json_response(_presets_load())


async def presets_post(request):
    """op=save(新建/覆盖) / rename / delete / current。params 是前端 collectParams() 的原样对象（含 persona_name/bg_name）。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    op = str(body.get("op", ""))
    with _PRESET_LOCK:
        d = _presets_load()
        ps = d["presets"]
        byid = {p["id"]: p for p in ps}
        pid = body.get("id")
        name = str(body.get("name", "")).strip()[:40]
        now = time.strftime("%Y-%m-%d %H:%M")
        if op == "save":
            params = body.get("params")
            if not isinstance(params, dict):
                return web.json_response({"error": "params 缺失"}, status=400)
            if pid and pid in byid:                          # 覆盖当前预设
                p = byid[pid]; p["params"] = params; p["updated"] = now
                if name: p["name"] = name
            else:                                            # 新建
                if not name:
                    return web.json_response({"error": "预设名不能为空"}, status=400)
                if any(p["name"] == name for p in ps):
                    return web.json_response({"error": "已有同名预设「%s」" % name}, status=400)
                pid = "p%d" % int(time.time() * 1000)
                p = {"id": pid, "name": name, "params": params, "created": now, "updated": now}
                ps.append(p)
            d["current"] = pid
        elif op == "rename":
            if pid not in byid: return web.json_response({"error": "预设不存在"}, status=404)
            if not name: return web.json_response({"error": "预设名不能为空"}, status=400)
            if any(p["name"] == name and p["id"] != pid for p in ps):
                return web.json_response({"error": "已有同名预设「%s」" % name}, status=400)
            byid[pid]["name"] = name; byid[pid]["updated"] = now
        elif op == "delete":
            if pid not in byid: return web.json_response({"error": "预设不存在"}, status=404)
            d["presets"] = [p for p in ps if p["id"] != pid]
            if d.get("current") == pid: d["current"] = None
        elif op == "current":
            d["current"] = pid if pid in byid else None
        else:
            return web.json_response({"error": "未知 op"}, status=400)
        _presets_save(d)
        log("预设 %s：%s" % (op, name or pid))
        return web.json_response(d)


async def delete_persona(request):
    """删除一张人设图。正在用的那张不让删，免得管线下一帧就找不到文件。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    raw = str(body.get("file", ""))
    if raw.startswith("dfm:"):                       # DFM 模型（§11.28）：只删用户添加的，内置的拒绝
        cur = getattr(_PIPELINE, "persona_name", "") if _PIPELINE else ""
        if raw == cur:
            return web.json_response({"error": "这是正在使用的人设，先切换到别的再删"}, status=400)
        try:
            import dfm_swapper as _D
            _D.remove_model(raw[4:])                  # 内置的也能删（进 hidden 名单，重新添加同一地址即恢复）
        except ValueError as e:
            return web.json_response({"error": str(e)}, status=400)
        except Exception as e:
            return web.json_response({"error": "删除失败: %s" % e}, status=500)
        log("删除 DFM 模型:", raw)
        return web.json_response({"ok": True, "items": _persona_list()})
    f = os.path.basename(raw)
    if not f.lower().startswith("persona") or not f.lower().endswith(PERSONA_EXT):
        return web.json_response({"error": "文件名不合法"}, status=400)
    cur = getattr(_PIPELINE, "persona_name", "") if _PIPELINE else ""
    if f == cur:
        return web.json_response({"error": "这是正在使用的人设，先切换到别的再删"}, status=400)
    try:
        os.remove(os.path.join(ASSETS_DIR, f))
    except FileNotFoundError:
        pass
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)
    labels = _persona_labels()
    labels.pop(f, None)
    try:
        with open(PERSONA_META, "w", encoding="utf-8") as fh:
            json.dump(labels, fh, ensure_ascii=False, indent=1)
    except Exception:
        pass
    return web.json_response({"ok": True, "items": _persona_list()})


# ---------------------------------------------------------------- 缩略图
# 素材网格如果直接挂原图会很慢（人设 PNG 近 2MB、背景 1080p JPG），
# 这里按需缩一份小图并缓存在内存里——素材不多，缓存上限够用且不会涨。
_THUMB = {}
_THUMB_MAX = 64


async def thumb(request):
    name = os.path.basename(request.match_info.get("file", ""))
    try:
        w = max(64, min(640, int(request.query.get("w", 256))))
    except ValueError:
        w = 256
    key = (name, w)
    hit = _THUMB.get(key)
    if hit is None:
        path = os.path.join(ASSETS_DIR, name)
        if not os.path.isfile(path):
            raise web.HTTPNotFound()
        import cv2
        import numpy as np
        # 🔴 用 imdecode + fromfile：路径含非 ASCII 时 imread 会静默失败（CLAUDE.md §5）
        img = cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            raise web.HTTPNotFound()
        h0, w0 = img.shape[:2]
        if w0 > w:
            img = cv2.resize(img, (w, max(1, int(h0 * w / w0))), interpolation=cv2.INTER_AREA)
        ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 82])
        if not ok:
            raise web.HTTPNotFound()
        hit = buf.tobytes()
        if len(_THUMB) >= _THUMB_MAX:
            _THUMB.pop(next(iter(_THUMB)))
        _THUMB[key] = hit
    return web.Response(body=hit, content_type="image/jpeg",
                        headers={"Cache-Control": "public, max-age=600"})


# ---------------------------------------------------------------- 背景与光照变体
BG_META = os.path.join(ASSETS_DIR, "backgrounds.json")
BG_LEGACY = os.path.join(ASSETS_DIR, "bglist.json")
BG_EXT = (".jpg", ".jpeg", ".png", ".webp")
VARIANT_SIDES = (("left", "左侧光"), ("right", "右侧光"), ("top", "顶光"))
SIDE_LABEL = dict(VARIANT_SIDES)
BG_MAX_SIDE = 1920


def _bg_labels():
    labels = {}
    try:
        with open(BG_LEGACY, "r", encoding="utf-8") as f:
            for e in json.load(f):
                labels[e["file"]] = e.get("label") or e["file"]
    except Exception:
        pass
    try:
        with open(BG_META, "r", encoding="utf-8") as f:
            labels.update(json.load(f))
    except Exception:
        pass
    return labels


def _save_bg_labels(labels):
    try:
        tmp = BG_META + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(labels, f, ensure_ascii=False, indent=1)
        os.replace(tmp, BG_META)
    except Exception as e:
        log("写背景标签失败:", e)


def _is_bg_file(name):
    low = name.lower()
    return (low.startswith("bg") or low == "background.jpg") and low.endswith(BG_EXT) \
        and low not in ("bglist.json", "backgrounds.json")


_BGH_RE = re.compile(r"^bgh__(.+)__(\d+)\.jpg$")


def _split_variant(name):
    """归类一个背景文件：返回 (家族 stem, 变体类型, 变体序号/side)。
      bg_office.jpg           -> ('bg_office', None, None)            原图
      bg_office__left.jpg     -> ('bg_office', 'side', 'left')        IC-Light 方向变体（实验通道）
      bgh__bg_office__3.jpg   -> ('bg_office', 'ai', 3)               AI 匹配候选（产品主路径，bg_harmonize）
    """
    m = _BGH_RE.match(name)
    if m:
        return m.group(1), "ai", int(m.group(2))
    stem, ext = os.path.splitext(name)
    if "__" in stem:
        base, side = stem.rsplit("__", 1)
        if side in SIDE_LABEL:
            return base, "side", side
    return stem, None, None


def _bgh_meta():
    """bgh_meta.json：{候选文件名: {bg_l, room_l, L_ai, structure, ...}}（bg_harmonize 写）。"""
    try:
        with open(os.path.join(ASSETS_DIR, "bgh_meta.json"), "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def _bg_families():
    labels = _bg_labels()
    meta = _bgh_meta()
    fams = {}
    try:
        names = sorted(os.listdir(ASSETS_DIR))
    except OSError:
        return []
    for f in names:
        if not _is_bg_file(f):
            continue
        base, kind, key = _split_variant(f)
        fam = fams.setdefault(base, {"file": None, "label": None, "url": None, "variants": []})
        if kind is None:
            fam["file"] = f
            fam["label"] = labels.get(f) or base.replace("bg_", "").replace("bgv_", "竖版·")
            fam["url"] = "/assets/" + f
        elif kind == "ai":
            mt = meta.get(f, {})
            fam["variants"].append({"file": f, "kind": "ai", "n": key, "label": "AI %d" % key,
                                    "url": "/assets/" + f, "bg_l": mt.get("bg_l"),
                                    "room_l": mt.get("room_l"), "structure": mt.get("structure")})
        else:
            fam["variants"].append({"file": f, "kind": "side", "side": key,
                                    "label": SIDE_LABEL[key], "url": "/assets/" + f})
    out = []
    for base, fam in fams.items():
        if fam["file"] is None:                 # 只剩变体没有原图：仍然展示，避免"文件在但看不见"
            fam["file"] = fam["variants"][0]["file"]
            fam["label"] = base
            fam["url"] = "/assets/" + fam["file"]
        # AI 候选按生成序号（=生成顺序，§11.18b user 拍板不按匹配度重排），方向变体排后
        fam["variants"].sort(key=lambda v: (0 if v["kind"] == "ai" else 1, v.get("n", 0), v.get("side", "")))
        job = _VJOBS.get(fam["file"])
        fam["job"] = {k: job[k] for k in ("status", "done", "total", "error", "current")} if job else None
        out.append(fam)
    out.sort(key=lambda x: (0 if x["file"] == "background.jpg" else 1, x["label"]))
    return out


def _bgh_status():
    """AI 匹配候选生成的状态，供前端轮询（不走 /stats：那里只在有帧流过时才刷新）。"""
    p = _PIPELINE
    if not (MODE == "pipeline" and p is not None):
        return {"state": 0, "running": False, "has_ref": False, "clicks": 0, "ok": 0,
                "keys": False, "src": None}
    threads = getattr(p, "_bgh_threads", []) or []
    return {"state": int(getattr(p, "_bgh_state", 0)),
            "running": any(t.is_alive() for t in threads),
            "has_ref": getattr(p, "_last_img", None) is not None,
            "clicks": int(getattr(p, "_bgh_clicks", 0)),
            "ok": int(getattr(p, "_bgh_ok", 0)),
            "keys": bool(getattr(p, "openai_key", "") or getattr(p, "venice_key", "")),
            "src": getattr(p, "_bgh_src", None)}


async def backgrounds(request):
    cur = ""
    if MODE == "pipeline" and _PIPELINE is not None:
        cur = getattr(_PIPELINE, "bg_name", "") or ""
    return web.json_response({"items": _bg_families(), "current": cur,
                              "sides": [{"side": k, "label": v} for k, v in VARIANT_SIDES],
                              "bgh": _bgh_status()})


async def upload_background(request):
    """上传一张背景图。会压到最长边 1920，存成 bg_<时间戳>.jpg。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    try:
        reader = await request.multipart()
        field = await reader.next()
        raw = bytearray()
        while True:
            chunk = await field.read_chunk(65536)
            if not chunk:
                break
            raw.extend(chunk)
            if len(raw) > 30 * 1024 * 1024:
                return web.json_response({"error": "图片太大了（上限 30MB）"}, status=400)
        label = (request.query.get("label") or field.filename or "我的背景").strip()
    except Exception as e:
        return web.json_response({"error": "读取上传内容失败: %s" % e}, status=400)
    import cv2
    import numpy as np
    img = cv2.imdecode(np.frombuffer(bytes(raw), np.uint8), cv2.IMREAD_COLOR)
    if img is None:
        return web.json_response({"error": "这个文件不是有效的图片"}, status=400)
    h, w = img.shape[:2]
    if w < 640 or h < 360:
        return web.json_response({"error": "背景图太小了（至少 640×360），会糊。请换一张更清晰的。"},
                                 status=400)
    scale = min(1.0, float(BG_MAX_SIDE) / max(h, w))
    if scale < 1.0:
        img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
    name = "bg_%d.jpg" % int(time.time() * 1000)
    ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 92])
    if not ok:
        return web.json_response({"error": "图片编码失败"}, status=500)
    with open(os.path.join(ASSETS_DIR, name), "wb") as f:
        f.write(buf.tobytes())
    labels = _bg_labels()
    labels[name] = label[:40]
    _save_bg_labels({k: v for k, v in labels.items() if _is_bg_file(k)})
    _THUMB.clear()
    log("新增背景:", name, label)
    return web.json_response({"ok": True, "file": name, "items": _bg_families()})


async def delete_background(request):
    """删原图 = 连同它的变体一起删；删变体 = 只删那一个。正在使用的不让删。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    f = os.path.basename(str(body.get("file", "")))
    if not _is_bg_file(f):
        return web.json_response({"error": "文件名不合法"}, status=400)
    if f == "background.jpg":
        return web.json_response({"error": "默认背景不能删除"}, status=400)
    cur = getattr(_PIPELINE, "bg_name", "") if _PIPELINE else ""
    base, side, _ = _split_variant(f)
    targets = [f]
    if side is None:                                   # 原图：把家族里的变体一起带走
        targets += [v["file"] for fam in _bg_families() if fam["file"] == f for v in fam["variants"]]
    if cur in targets:
        return web.json_response({"error": "这是正在使用的背景，先切换到别的再删"}, status=400)
    if _VJOBS.get(f, {}).get("status") == "running":
        return web.json_response({"error": "这个背景正在生成变体，请等它完成"}, status=400)
    for t in targets:
        try:
            os.remove(os.path.join(ASSETS_DIR, t))
        except FileNotFoundError:
            pass
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)
    labels = _bg_labels()
    for t in targets:
        labels.pop(t, None)
    _save_bg_labels({k: v for k, v in labels.items() if _is_bg_file(k)})
    _THUMB.clear()
    return web.json_response({"ok": True, "items": _bg_families()})


# ---- 变体生成：IC-Light 重打光，跑在单工作线程里，不阻塞 aiohttp 也不并发占显存 ----
_VJOBS = {}
_RELIGHTER = None
_RELIGHT_LOCK = threading.Lock()
_VPOOL = concurrent.futures.ThreadPoolExecutor(max_workers=1)


def _get_relighter():
    """IC-Light 引擎懒加载单例（首次约 60s + 下载权重），之后每张 1–3s。"""
    global _RELIGHTER
    with _RELIGHT_LOCK:
        if _RELIGHTER is None:
            if HERE not in sys.path:
                sys.path.insert(0, HERE)
            from offline_bg.iclight import ICLightRelighter   # 包随代码一起部署到 HERE/offline_bg
            log("加载 IC-Light 重打光引擎…")
            _RELIGHTER = ICLightRelighter(verbose=True)
            log("IC-Light 就绪")
        return _RELIGHTER


def _work_size(w, h, max_side=1024):
    """IC-Light 要求边长是 64 的倍数；按原图比例取不超过 max_side 的最接近尺寸。"""
    scale = min(1.0, float(max_side) / max(w, h))
    tw = max(64, int(round(w * scale / 64.0)) * 64)
    th = max(64, int(round(h * scale / 64.0)) * 64)
    return tw, th


def _run_variants(fname, sides):
    import cv2
    import numpy as np
    job = _VJOBS[fname]
    try:
        from offline_bg.config import DEFAULT as CFG
        from offline_bg.validate import score_direction, best_of
        r = _get_relighter()
        path = os.path.join(ASSETS_DIR, fname)
        img = cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            raise FileNotFoundError(fname)
        h, w = img.shape[:2]
        tw, th = _work_size(w, h)
        small = cv2.resize(img, (tw, th), interpolation=cv2.INTER_AREA)
        base, _, _ = _split_variant(fname)
        for side in sides:
            job["current"] = side
            cands, scores = [], []
            # 🔴 闭环校验（CLAUDE.md §5/§9.4）：按 prompt/渐变图指定方向并不保证成功，
            #    必须量化验证方向增量，不达标就抬 strength + 换 seed 重试；全败取最接近的落库并标记。
            for i, strength in enumerate(CFG.retry_strengths):
                out = r.relight(small, side, strength=strength, seed=i)
                sc = score_direction(small, out, side, CFG)
                cands.append(out)
                scores.append(sc)
                log("变体 %s/%s 第%d次: %s" % (fname, side, i + 1, sc))
                if sc["ok"]:
                    break
            k = len(scores) - 1 if scores[-1]["ok"] else best_of(scores)
            full = cv2.resize(cands[k], (w, h), interpolation=cv2.INTER_CUBIC)
            ok, buf = cv2.imencode(".jpg", full, [cv2.IMWRITE_JPEG_QUALITY, 92])
            if not ok:
                raise RuntimeError("变体编码失败")
            out_name = "%s__%s.jpg" % (base, side)
            with open(os.path.join(ASSETS_DIR, out_name), "wb") as f:
                f.write(buf.tobytes())
            job["done"].append({"side": side, "file": out_name, "validated": bool(scores[k]["ok"]),
                                "delta": scores[k]["signed_delta"]})
            _THUMB.clear()
        job["status"] = "done"
        job["current"] = None
        log("变体生成完成:", fname, [d["side"] for d in job["done"]])
    except Exception as e:
        job["status"] = "error"
        job["error"] = "%s: %s" % (type(e).__name__, e)
        job["current"] = None
        log("变体生成失败:", fname, job["error"])
        traceback.print_exc()


async def gen_variants(request):
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    f = os.path.basename(str(body.get("file", "")))
    if not _is_bg_file(f) or not os.path.isfile(os.path.join(ASSETS_DIR, f)):
        return web.json_response({"error": "背景文件不存在"}, status=400)
    if _split_variant(f)[1] is not None:
        return web.json_response({"error": "请对原图生成变体，不要对变体再生成"}, status=400)
    sides = [x for x in (body.get("sides") or [k for k, _ in VARIANT_SIDES]) if x in SIDE_LABEL]
    if not sides:
        return web.json_response({"error": "没有可生成的方向"}, status=400)
    if _VJOBS.get(f, {}).get("status") == "running":
        return web.json_response({"ok": True, "job": _VJOBS[f], "note": "已在生成中"})
    _VJOBS[f] = {"status": "running", "done": [], "total": len(sides), "error": "",
                 "current": None, "started": time.time()}
    _VPOOL.submit(_run_variants, f, sides)
    log("开始生成变体:", f, sides)
    return web.json_response({"ok": True, "job": _VJOBS[f]})


async def variant_status(request):
    f = os.path.basename(request.query.get("file", ""))
    job = _VJOBS.get(f)
    if not job:
        return web.json_response({"status": "idle"})
    return web.json_response({k: job.get(k) for k in ("status", "done", "total", "error", "current")})


# 网页版从 https 门户页探 http://127.0.0.1:<port>/health 判「这台 GPU 就在本机」：浏览器对 127.0.0.1 不拦混合内容，
# 但要 CORS 头；Chrome 的本地网络访问（PNA/LNA）还会先发带 Access-Control-Request-Private-Network 的预检。
# /health 本来就不要 token、只有统计信息，放开无风险。
CORS_H = {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS",
          "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Private-Network": "true",
          "Access-Control-Max-Age": "600"}


async def health_options(request):
    return web.Response(status=204, headers=CORS_H)


async def health(request):
    return web.json_response(headers=CORS_H, data={
        "ok": True, "mode": MODE, "python": platform.python_version(),
        "pid": os.getpid(),
        "public_ip": os.environ.get("PUBLIC_IP"),
        "ext_tcp": os.environ.get("VAST_TCP_PORT_8080"),
        "ext_udp": os.environ.get("VAST_UDP_PORT_8081"),
        "tunnel": TUNNEL,
        "ice": {"port": ICE_PORT, "public_host": PUB_HOST, "ext_udp": PUB_UDP},
        "stats": STATS.snapshot(),
    })


async def stats(request):
    s = STATS.snapshot()
    s["rev"] = TUNE_REV                               # 参数版本号（协助页/用户页双向同步用，§15.11）
    s["watch"] = {"n": len(WATCHERS), "seq": TAP.seq,
                  "age_s": round(time.time() - TAP.ts, 1) if TAP.ts else None}   # 标志位：旁观者数 / tap 帧序号 / 最新帧距今
    return web.json_response(s)


async def params(request):
    """当前所有可调参数 + 可用修复模型，供 web 控制面板初始化控件。"""
    p = _PIPELINE
    if p is None:
        return web.json_response({"loaded": False, "rev": TUNE_REV})
    return web.json_response({
        "loaded": True,
        "rev": TUNE_REV,
        "enhancers": list(getattr(p, "enhancers", {}).keys()),
        "params": {
            "use_bg": p.use_bg, "use_enhancer": p.use_enhancer, "enhancer_model": p.enhancer_model,
            "enhancer_blend": p.enhancer_blend, "enhancer_weight": p.enhancer_weight,
            "use_occluder": p.use_occluder, "occ_every": p.occ_every,
            "alpha_lo": p.alpha_lo, "alpha_ema": p.alpha_ema, "det_every": p.det_every,
            "kps_ema": p.kps_ema, "bg_mode": p.bg_mode,
            "chroma_lo": p.chroma_lo, "chroma_hi": p.chroma_hi,
            "matte_engine": p.matte_engine,
            "seg_erode": p.seg_erode, "seg_blur": p.seg_blur, "edge_repair": p.edge_repair, "repair_core": p.repair_core,
            "bg_name": p.bg_name,
            "use_seg_floor": p.use_seg_floor, "seg_every": p.seg_every,
            "seg_lo": p.seg_lo, "seg_hi": p.seg_hi,
            # §14.7 抠像链 A/B：这三项 2026-08-14 / 08-31 加进 master，8 月分支没有；前端要能回读状态
            "use_prior": getattr(p, "use_prior", True), "gpu_seg_chain": getattr(p, "gpu_seg_chain", True),
            "seg_gate_pct": getattr(p, "seg_gate_pct", 90),
            # 🔴 2026-09-02 合并分支后调色模型以分支为准（§11.20 user 拍板的「人物/背景/整体」三组）：
            #    旧的 bg_brightness/bg_gamma/bg_warm/bg_blur 已不存在，别再往这里加，会 500。
            "fg_bright": p.fg_bright, "fg_sat": p.fg_sat, "fg_contrast": p.fg_contrast,
            "bg_bright": p.bg_bright, "bg_sat": p.bg_sat, "bg_contrast": p.bg_contrast,
            "bg_sharp": p.bg_sharp,        # link_* 是浏览器端 CSS 滤镜，不在管线上，别加进来
            # 分支合入：环境光校正（§11.13）/ 脸缘融合（§11.21）/ AI 匹配候选锐度对齐
            "use_relight": p.use_relight, "relight_strength": p.relight_strength,
            "relight_coefs": getattr(p, "relight_coefs", None),      # 预设存/取环境光校正系数（2026-09-07）
            "edge_converge": p.edge_converge, "bgh_sharpen": p.bgh_sharpen,
            "decontam": p.decontam,
            "persona_name": p.persona_name,
            # DFM 专属模型（§11.28）：肤色匹配开关 / AMP morph
            "dfm_color_match": getattr(p, "dfm_color_match", True), "dfm_morph": getattr(p, "dfm_morph", 1.0),
            # 局部光照迁移 / 表情迁移（§11.30）
            "local_color": getattr(p, "local_color", True), "local_color_strength": getattr(p, "local_color_strength", 0.65),
            "use_expr": getattr(p, "use_expr", False), "expr_factor": getattr(p, "expr_factor", 0.8),
            "expr_area": getattr(p, "expr_area", "all"), "expr_ema": getattr(p, "expr_ema", 0.0),
            "expr_state": getattr(p, "_expr_state", 0), "expr_msg": getattr(p, "_expr_msg", ""),
        }})


async def ls(request):
    """调试用：列目录。SSH 不通的情况下，这是唯一能看清实例上文件状态的手段。"""
    d = request.query.get("p") or HERE
    try:
        out = []
        for f in sorted(os.listdir(d)):
            fp = os.path.join(d, f)
            out.append({"name": f, "size": os.path.getsize(fp) if os.path.isfile(fp) else None,
                        "dir": os.path.isdir(fp)})
        return web.json_response({"path": d, "items": out})
    except Exception as e:
        return web.json_response({"path": d, "error": f"{type(e).__name__}: {e}"})


async def logs(request):
    return web.Response(text="\n".join(LOG), content_type="text/plain")


async def _bg_close(pc):
    """后台兜底关闭一个关不掉的旧连接，失败也不影响主流程。"""
    try:
        await asyncio.wait_for(pc.close(), timeout=30)
    except Exception:
        pass


async def offer(request):
    params = await request.json()
    # 🔴 服务端也要配 STUN：容器在 docker NAT 后面，外部端口 ≠ 内部端口，
    #    host candidate 报的端口浏览器连不上。靠 srflx candidate + ICE 打洞
    #    （双方都往对方发包，NAT 各自建映射；浏览器还会学到 peer-reflexive 地址）。
    # 🔴 固定 ICE 端口意味着同一时刻只能有一个连接占着它。新连接进来先关掉旧的，
    #    否则新连接绑不上端口 → candidate 不可达 → 连不上（实测第二次连接直接 0 帧）。
    # 🔴 close() 必须带超时：客户端被强杀/断网时对端已经消失，aiortc 关闭时要等
    #    DTLS/ICE 收尾，会一直卡住 —— 而它就卡在 /offer 里，于是**整个服务再也接不了
    #    新连接**（现象：/health 正常但 /offer 永远超时，只能重启服务端）。
    #    2026-09-01 实测踩到：强杀几个测试客户端后服务端就废了。
    for old in list(PCS):
        PCS.discard(old)                      # 先摘掉，无论关得掉关不掉都不再算数
        # 🔴 对端已经断开（connectionState 已是 closed/failed）的旧连接，aiortc 的 close() 会挂住 >60s（2026-09-06 实测：
        #    客户端 23:19:50 正常 stop，服务端同秒记到 closed，23:21:05 再关它仍超时）。等它 5s 纯属浪费，而且这正是
        #    「stop 之后再点开始偶尔要多等 5 秒」的来源。已断开的直接丢后台关，只对仍活着的连接等 5s 让它释放 ICE 端口。
        if old.connectionState in ("closed", "failed"):
            log("上一个连接已断开(%s)，后台清理不等待" % old.connectionState)
            asyncio.ensure_future(_bg_close(old))
            continue
        log("关闭上一个连接以释放 ICE 端口")
        try:
            await asyncio.wait_for(old.close(), timeout=5)
        except Exception as e:
            # 关不掉就丢到后台继续关，绝不让它拖住新连接
            log("旧连接关闭超时/失败(%s)，转后台处理" % type(e).__name__)
            asyncio.ensure_future(_bg_close(old))
    # 🔴 STUN 只在 NAT 后端（Vast）需要，由部署脚本写进 node_config.stun；ai002 与客户端同在 Meshnet/局域网，
    #    不配 STUN。配了就要等 aioice 收集完成：本机 7 个网卡里 169.254.* 那些永远等不到应答，固定 5s 超时——
    #    这就是「点开始要等很久」里服务端那 5 秒（客户端那侧同款，见 desktop/media.py）。2026-09-05 实测定位。
    ice = _ice_server_objs()
    if ice:
        log("ICE 服务器:", [u for s_ in ice for u in (s_.urls if isinstance(s_.urls, list) else [s_.urls])])
    pc = RTCPeerConnection(RTCConfiguration(iceServers=ice))
    PCS.add(pc)
    log("新连接，当前", len(PCS), "个")

    @pc.on("connectionstatechange")
    async def on_state():
        log("连接状态:", pc.connectionState)
        if pc.connectionState in ("failed", "closed"):
            PCS.discard(pc)                   # 先摘掉再关：close() 可能挂很久，挂着期间不能还占着 PCS 让下一个 /offer 去等它
            asyncio.ensure_future(_bg_close(pc))

    @pc.on("track")
    def on_track(track):
        log("收到轨道:", track.kind)
        if track.kind == "video":
            pc.addTrack(ProcTrack(track))

    await pc.setRemoteDescription(RTCSessionDescription(params["sdp"], params["type"]))
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    # 等 ICE 收集完（aiortc 是 vanilla ICE，answer 里要带全 candidate）
    return web.json_response({"sdp": inject_candidate(pc.localDescription.sdp),
                              "type": pc.localDescription.type})


async def watch_offer(request):
    """远程协助旁观流（§15.11）：管理员浏览器发来两条 recvonly 视频 m-line 的 offer，节点回 answer 并只发不收：
    mid 0 = 对方摄像头原图（TAP.inp），mid 1 = 换脸结果（TAP.out）。
    🔴 与 /offer 的三点不同，缺一不可：
      ① 不动 PCS（/offer 会把 PCS 全清场，旁观者进 WATCHERS）；
      ② 先 addTrack 两条 TapTrack 再 setRemoteDescription（aiortc 按顺序把未关联的 transceiver 配给 offer 的 m-line，
         方向由 recvonly 推成 sendonly），全程没有入站轨道 → 永不触发 on_track → 不会 reset 共享管线；
      ③ ICE 在 WATCH_CTX 下收集：绑 8082 不抢 8081，且不注入主连接的映射 candidate（NAT 后的 Vast 节点靠 TURN，节点到 coturn 是出站）。
    鉴权：门户隧道层只放行 admin 到 watch/*，节点侧仍查 X-Token。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    params = await request.json()
    for w in list(WATCHERS):                          # 先清掉已断开的旁观者，再判上限
        if w.connectionState in ("closed", "failed"):
            WATCHERS.discard(w)
            asyncio.ensure_future(_bg_close(w))
    if len(WATCHERS) >= WATCH_MAX:
        return web.json_response({"error": "旁观连接已达上限 %d，请先结束其他协助" % WATCH_MAX}, status=429)
    pc = RTCPeerConnection(RTCConfiguration(iceServers=_ice_server_objs()))
    WATCHERS.add(pc)
    log("旁观连接协商中，当前 %d 个（主连接 %d 个）" % (len(WATCHERS), len(PCS)))

    @pc.on("connectionstatechange")
    async def on_state():
        log("旁观连接状态:", pc.connectionState)
        if pc.connectionState in ("failed", "closed"):
            WATCHERS.discard(pc)
            asyncio.ensure_future(_bg_close(pc))

    pc.addTrack(TapTrack("in"))
    pc.addTrack(TapTrack("out"))
    tok = WATCH_CTX.set(True)
    try:
        await pc.setRemoteDescription(RTCSessionDescription(params["sdp"], params["type"]))
        answer = await pc.createAnswer()
        await pc.setLocalDescription(answer)
    except Exception as e:
        WATCHERS.discard(pc)
        asyncio.ensure_future(_bg_close(pc))
        log("旁观连接协商失败:", type(e).__name__, e)
        return web.json_response({"error": "%s: %s" % (type(e).__name__, e)}, status=500)
    finally:
        WATCH_CTX.reset(tok)
    return web.json_response({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type})


async def watch_stop(request):
    """关闭全部旁观连接：agent 在 session_end 时调（会话结束旁观者没有东西可看），门户「结束协助」也可调。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    n = len(WATCHERS)
    for w in list(WATCHERS):
        WATCHERS.discard(w)
        asyncio.ensure_future(_bg_close(w))
    if n:
        log("旁观连接已全部关闭:", n)
    return web.json_response({"ok": True, "closed": n})


async def reload_code(request):
    """🔴 热重载：不重启进程(端口不掉)，只丢掉缓存的管线 + 清模块缓存，下次 /mode pipeline
    重新 import 磁盘上的新代码。配合 /deploy(restart=false) 落盘后调用——从此改管线代码
    不必换机、不必重启：pushp 落盘 → /reload → /mode pipeline → 重连即用新代码。
    ⚠️ 只重载管线侧模块；改 server.py 本身(handler/路由)仍需换机(它已 import 且在执行)。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    global _PIPELINE, MODE
    _PIPELINE = None                      # 丢掉单例 → 旧 torch/ort 会话被 GC 释放 GPU
    # 🔴 通用扫描而非写死清单：凡源文件在本目录的模块全清（server 自身除外）。写死清单的教训=
    #    后加的 birefnet.py 不在清单里 → 重载后 import 到旧缓存 ImportError 被吞 → 静默退化。
    dropped = []
    for m, mod in list(sys.modules.items()):
        f = getattr(mod, "__file__", None) or ""
        if f.startswith(HERE) and m != "server" and "server.py" not in f:
            del sys.modules[m]; dropped.append(m)
    MODE = "loopback"                     # 落回 loopback，等 /mode pipeline 再懒加载新代码
    open(os.path.join(HERE, ".mode"), "w").write(MODE)
    STATS.last_error = None
    log("热重载：丢弃管线单例 + 清模块", dropped)
    return web.json_response({"ok": True, "dropped": dropped, "mode": MODE})


async def set_mode(request):
    """运行时切换处理模式，不重启服务。管线在启动时已预加载，切换只是改标志位。"""
    global MODE
    m = (await request.json()).get("mode")
    if m not in ("loopback", "pipeline"):
        return web.json_response({"error": "mode must be loopback|pipeline"}, status=400)
    if m == "pipeline":
        try:
            get_pipeline().reset()
        except Exception as e:
            STATS.last_error = f"管线加载失败 {type(e).__name__}: {e}"
            return web.json_response({"error": STATS.last_error}, status=500)
    MODE = m
    open(os.path.join(HERE, ".mode"), "w").write(m)   # 重启后保持
    STATS.last_error = None
    log("模式切换 →", m)
    return web.json_response({"ok": True, "mode": MODE})


async def tune(request):
    """运行时调管线参数，不重启、不重连。用于 A/B 对照（如 use_fgr 开/关）。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    try:
        p = get_pipeline()
    except Exception as e:
        return web.json_response({"error": str(e)}, status=500)
    # 🔴 通用放行：凡管线上已存在的公开属性都可调（旧版写死白名单 → 每加一个参数就要重启
    #    换机；而 server.py 已 import、/reload 不覆盖它）。只挡下划线开头与可调用对象。
    applied = {}
    for k, v in body.items():
        if k.startswith("_") or not hasattr(p, k) or callable(getattr(p, k, None)):
            continue
        setattr(p, k, v)
        applied[k] = v
    p.reset()
    STATS.__init__()                      # 参数变了，旧的统计不能混在一起
    STATS.last_error = None
    global TUNE_REV
    TUNE_REV += 1                         # 版本号自增：另一端（协助页/用户页）在 /stats 里看到 rev 变了就重读 /params 刷新控件
    # 🔴 key 类参数（openai_key/venice_key/fal_key…）只记名字不记值：日志文件会被拷来拷去，明文 key 进日志等于泄露（2026-09-06 发现）
    log("调参:", {k: ("<%d chars>" % len(str(v))) if k.endswith("_key") or "token" in k else v for k, v in applied.items()})
    return web.json_response({"ok": True, "rev": TUNE_REV,
                              "applied": {k: True if (k.endswith("_key") or "token" in k) else v for k, v in applied.items()}})


async def debug_frame(request):
    """喂一帧进去，把管线的**中间结果**全部吐出来（base64 PNG）。

    为什么需要：靠 WebRTC 反复跑太慢且不可复现，而边缘白边/遮挡穿帮这类问题
    必须看 alpha、fgr、换脸前后这些中间量才能定位，光看成品只能猜。
    """
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    import cv2
    import numpy as np
    import torch
    body = await request.json()
    img = cv2.imdecode(np.frombuffer(base64.b64decode(body["image"]), np.uint8),
                       cv2.IMREAD_COLOR)
    p = get_pipeline()
    p.reset()
    out = {}
    with torch.no_grad():
        h, w = img.shape[:2]
        raw = img.copy()
        face = None
        fs = p.app.get(img)
        if fs:
            face = max(fs, key=lambda f: (f.bbox[2]-f.bbox[0])*(f.bbox[3]-f.bbox[1]))
            # 🔴 遮挡感知 A/B：同一帧分别跑「关/开」遮挡感知，直接看出手遮脸时的差别。
            #    关：手连同脸被换掉（手变糊、透出鼻嘴）；开：手保留原像素。
            p.roi.occluder = None
            out["swapped"] = p.roi(img.copy(), face)                 # 无遮挡感知
            if p.occluder is not None:
                # 原始 xseg 遮罩可视化：确认语义方向（高=脸区 还是 高=遮挡区）。
                # 把对齐空间的遮罩 warp 回全帧，白=模型认为「露出的脸/该换」。
                from insightface.utils import face_align
                aimg, M = face_align.norm_crop2(raw, face.kps, p.roi.S)
                occ = p.occluder.face_mask(aimg, out_size=(p.roi.S, p.roi.S))
                IM = cv2.invertAffineTransform(M)
                occ_full = cv2.warpAffine((occ * 255).astype(np.uint8), IM, (w, h))
                out["occ_mask"] = cv2.cvtColor(occ_full, cv2.COLOR_GRAY2BGR)
                p.roi.occluder = p.occluder
                out["swapped_occ"] = p.roi(img.copy(), face)         # 开遮挡感知
                p.roi.occluder = None
        else:
            out["swapped"] = img
        # 下游抠像/合成用「实际管线输出」的换脸结果（默认开遮挡感知），未换脸则用原图
        img = out.get("swapped_occ") if out.get("swapped_occ") is not None else out["swapped"]
        from validated_impl import to_gpu, from_gpu
        x = to_gpu(img)
        ratio = body.get("downsample") or p._auto_ratio(h, w)
        # 循环网络：喂几帧让状态收敛，否则首帧 alpha 偏差大
        rec = [None] * 4
        for _ in range(int(body.get("warmup", 3))):
            fgr, pha, *rec = p.rvm(x, *rec, downsample_ratio=ratio)
        bg = p._bg_for(h, w)
        out["alpha"] = from_gpu(pha.repeat(1, 3, 1, 1))
        out["fgr"] = from_gpu(fgr)
        out["comp_fgr"] = from_gpu(pha * fgr + (1 - pha) * bg)
        out["comp_raw"] = from_gpu(pha * x + (1 - pha) * bg)
        out["face_box"] = [float(v) for v in face.bbox] if face is not None else None
        out["ds_ratio"] = ratio
    enc = {}
    for k, v in out.items():
        if hasattr(v, "shape"):
            ok, buf = cv2.imencode(".png", v)
            enc[k] = base64.b64encode(buf.tobytes()).decode()
        else:
            enc[k] = v
    return web.json_response(enc)


async def deploy(request):
    """🔴 SSH 的替代：接收代码包 → 落盘 →（可选）进程自杀让 onstart 拉起。

    🔴 restart=false（默认路径，2026-08-12 加）：**只落盘不重启**。因为 os._exit 重启在约
       50% 的 vast 宿主上会让端口转发失效（loopback 可达、push 完 TCP 全超时，与轮询无关）。
       配套用法：loopback 起服务（不 import pipeline 模块）→ deploy(restart=false) 落盘 pipeline
       代码+资源 →（另发）/mode pipeline，在**同进程内**首次 import 刚落盘的 pipeline_rt 并懒加载。
       全程无重启、端口不掉，所有宿主都可靠。只有改了 server.py 本身才需要 restart=true。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    body = await request.json()
    blob = lzma.decompress(base64.b64decode(body["payload"])).decode("utf-8")
    n = 0
    # 🔴 分隔符必须拼出来，不能写成字面量：这个文件自己也在载荷里，
    #    出现字面量会导致解包时把本文件的代码从这里切成两半（踩过）
    for part in blob.split("@@" + "FILE:")[1:]:
        head, _, content = part.partition("@@\n")
        p = os.path.join(HERE, head)
        os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
        open(p, "w", encoding="utf-8").write(content)
        n += 1
    # 二进制资源（人设图/背景图）走单独字段，base64 传
    for name, b64 in (body.get("assets") or {}).items():
        ap = os.path.join(HERE, "assets", name)
        os.makedirs(os.path.dirname(ap), exist_ok=True)
        open(ap, "wb").write(base64.b64decode(b64))
        n += 1
    if body.get("mode"):
        open(os.path.join(HERE, ".mode"), "w").write(body["mode"])
    restart = body.get("restart", True)
    log(f"热更新 {n} 个文件，模式→{body.get('mode', MODE)}，restart={restart}")
    if not restart:
        return web.json_response({"ok": True, "files": n, "restarted": False})

    async def bye():
        await asyncio.sleep(0.4)
        os._exit(0)                       # 由 onstart 的 while true 拉起来

    asyncio.ensure_future(bye())
    return web.json_response({"ok": True, "files": n, "restarted": True})


TUNNEL = {"url": None, "state": "off"}


def read_tunnel():
    """隧道地址由外部独立进程写入文件；服务只读不管理。

    🔴 教训：原先每次服务启动都 spawn 一条新的 quick tunnel，热更新重启十几次后
       被 Cloudflare 限流（429 Too Many Requests，错误码 1015）。隧道的生命周期
       必须独立于应用进程。
    """
    p = os.path.join(HERE, "tunnel_url.txt")
    if os.path.exists(p):
        u = open(p).read().strip()
        if u:
            TUNNEL.update(url=u, state="up")
    return TUNNEL


def start_tunnel(port):
    """起一条 Cloudflare 快速隧道，给页面一个 HTTPS 域名。

    🔴 为什么必须要：①浏览器不让访问裸 IP+HTTP 的页面；②`getUserMedia`（真实摄像头）
       强制要求安全上下文，没有 HTTPS 就永远测不了摄像头。
    🔴 不违反「视频流不过 CF」的架构铁律：隧道只承载 HTML 与 /offer 信令（几 KB），
       媒体是 WebRTC UDP 由浏览器直连本机公网 IP，不经过 CF。
    🔴 --no-autoupdate 必须加：cloudflared 自动更新会把自己重启掉、隧道静默死。
    """
    import shutil
    import subprocess
    import threading
    exe = shutil.which("cloudflared") or "/usr/local/bin/cloudflared"
    if not os.path.exists(exe):
        url = ("https://github.com/cloudflare/cloudflared/releases/latest/download/"
               "cloudflared-linux-amd64")
        log("下载 cloudflared …")
        import urllib.request as U
        U.urlretrieve(url, exe)
        os.chmod(exe, 0o755)
    p = subprocess.Popen([exe, "tunnel", "--url", f"http://127.0.0.1:{port}",
                          "--no-autoupdate", "--loglevel", "info"],
                         stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    TUNNEL["state"] = "starting"

    def reader():
        import re
        for line in p.stdout:
            m = re.search(r"https://[-\w]+\.trycloudflare\.com", line)
            if m and not TUNNEL["url"]:
                TUNNEL["url"] = m.group(0)
                TUNNEL["state"] = "up"
                log("★ 隧道地址:", TUNNEL["url"])
        TUNNEL["state"] = "dead"
        log("⚠️ cloudflared 退出")

    threading.Thread(target=reader, daemon=True).start()


async def sh(request):
    """受 token 保护的 shell 端点。SSH 不通时这是唯一能在运行中的实例上修东西的手段
    （否则每修一次依赖就要重开一台机、重拉 5 分钟镜像）。临时开发实例专用，用完即销毁。"""
    if request.headers.get("X-Token") != TOKEN:
        return web.json_response({"error": "bad token"}, status=403)
    import subprocess
    cmd = (await request.json())["cmd"]
    log("SH:", cmd[:200])
    r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=1800)
    # 🔴 输出保头也保尾：真根因常在尾部（截断方向搞反过一次）
    def cut(t, n=3000):
        if len(t) <= n:
            return t
        return t[:n // 4] + "  …省略 %d 字…  " % (len(t) - n) + t[-(n * 3 // 4):]
    return web.json_response({"rc": r.returncode, "out": cut(r.stdout), "err": cut(r.stderr)})


async def on_shutdown(app):
    await asyncio.gather(*[pc.close() for pc in list(PCS) + list(WATCHERS)])
    PCS.clear()
    WATCHERS.clear()


def main():
    global MODE
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8080)
    a = ap.parse_args()
    mp = os.path.join(HERE, ".mode")
    if os.path.exists(mp):
        MODE = open(mp).read().strip() or MODE
    log(f"启动 mode={MODE} port={a.port} pid={os.getpid()} "
        f"ice_port={ICE_PORT} 对外={PUB_HOST}:{PUB_UDP}")
    patch_aioice_fixed_port(ICE_PORT)
    patch_aioice_gather()
    read_tunnel()
    if MODE == "pipeline":
        try:
            get_pipeline()
            log("管线预加载完成")
        except Exception as e:
            log("管线预加载失败:", type(e).__name__, e)
            log(traceback.format_exc()[-1200:])
    app = web.Application(client_max_size=64 * 1024 * 1024)
    app.on_shutdown.append(on_shutdown)
    app.router.add_get("/", index)
    app.router.add_get("/thumb/{file}", thumb)
    app.router.add_get("/backgrounds", backgrounds)
    app.router.add_post("/upload_background", upload_background)
    app.router.add_post("/delete_background", delete_background)
    app.router.add_post("/gen_variants", gen_variants)
    app.router.add_get("/variant_status", variant_status)
    app.router.add_get("/personas", personas)
    app.router.add_post("/upload_persona", upload_persona)
    app.router.add_post("/delete_persona", delete_persona)
    app.router.add_post("/add_dfm", add_dfm)
    app.router.add_get("/presets", presets_get)
    app.router.add_post("/presets", presets_post)
    app.router.add_get("/version", version)
    app.router.add_get("/download", download)
    app.router.add_get("/health", health)
    app.router.add_options("/health", health_options)
    app.router.add_get("/stats", stats)
    app.router.add_get("/params", params)
    app.router.add_get("/logs", logs)
    app.router.add_get("/ls", ls)
    app.router.add_post("/offer", offer)
    app.router.add_post("/watch/offer", watch_offer)      # 远程协助旁观流（§15.11）
    app.router.add_post("/watch/stop", watch_stop)
    app.router.add_post("/mode", set_mode)
    app.router.add_post("/reload", reload_code)
    app.router.add_post("/tune", tune)
    app.router.add_post("/debug_frame", debug_frame)
    app.router.add_post("/deploy", deploy)
    app.router.add_post("/sh", sh)
    os.makedirs(DIST, exist_ok=True)
    app.router.add_static("/download", DIST, show_index=False)
    app.router.add_static("/assets", os.path.join(HERE, "assets"), show_index=True)
    # 🔴 必须 HTTPS：getUserMedia（真实摄像头）只在安全上下文可用，http://IP 一定拿不到摄像头。
    #    自签证书浏览器会警告一次，点「高级 → 继续」之后就是安全上下文。
    cert = os.path.join(HERE, "cert.pem")
    key = os.path.join(HERE, "key.pem")
    ctx = None
    if os.path.exists(cert) and os.path.exists(key):
        import ssl as _ssl
        ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(cert, key)
        log("以 HTTPS 启动（自签证书）")
    else:
        log("⚠️ 没有证书，退回 HTTP —— 浏览器将无法使用摄像头")
    web.run_app(app, host="0.0.0.0", port=a.port, access_log=None, ssl_context=ctx)


if __name__ == "__main__":
    main()
