#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FaceSwapApp — 视频换脸 + 直播换脸+变声  All-in-One (v2.2)
支持原生摄像头 / OBS虚拟摄像头
"""

import os, sys, threading, time, queue, struct, subprocess
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from tkinter.scrolledtext import ScrolledText
import numpy as np
from PIL import Image, ImageTk
import cv2

# ─── 换衣 + 换背景 ───
try:
    from face_swap_features import BackgroundChanger, ClothesChanger, SimpleClothesChanger
    _HAS_FEATURES = True
except ImportError as e:
    print(f"⚠️ 换衣/换背景模块未加载: {e}")
    _HAS_FEATURES = False
    BackgroundChanger = None
    ClothesChanger = None
    SimpleClothesChanger = None

# ─── 全局 ───
APP_DIR = os.path.dirname(os.path.abspath(__file__))
PRESET_DIR = os.path.join(APP_DIR, "presets")
os.makedirs(PRESET_DIR, exist_ok=True)

def _safe_imread(path):
    """支持中文路径的图片读取"""
    try:
        with open(path, 'rb') as f:
            data = f.read()
        img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
        if img is not None:
            return img
    except:
        pass
    try:
        from PIL import Image as PILImg
        pil_img = PILImg.open(path)
        if pil_img.mode == 'RGBA':
            pil_img = pil_img.convert('RGB')
        return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
    except:
        return None

def _preset_display_name(filename):
    name = os.path.splitext(filename)[0]
    name = name.replace("_", " ").replace("-", " ").title()
    mapping = {"zhuren": "主人", "me": "我", "myself": "我自己"}
    return mapping.get(name.lower(), name)

def scan_cameras():
    """扫描所有可用摄像头，返回 [(OpenCV索引, DirectShow名称), ...]"""
    results = []
    # 用 pygrabber 获取设备名（不打开摄像头，只查名称）
    dshow_names = []
    try:
        from pygrabber.dshow_graph import FilterGraph
        graph = FilterGraph()
        dshow_names = graph.get_input_devices()
    except:
        pass
    # 扫 OpenCV 索引 - 用 MSMF 替代 DShow 避免锁摄像头
    obs_idx = None
    for i in range(10):
        try:
            cap = cv2.VideoCapture(i)
            if cap.isOpened():
                # 只查属性，不读帧（避免锁摄像头）
                w = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
                h = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
                if w > 0 and h > 0:
                    name = dshow_names[len(results)] if len(results) < len(dshow_names) else f"摄像头 {i}"
                    results.append((i, name))
                    if 'obs' in name.lower() or 'virtual' in name.lower():
                        obs_idx = i
                cap.release()
        except:
            pass
        # 每扫一个等一会，避免DShow冲突
        import time
        time.sleep(0.1)
    return results, obs_idx

def find_obs_index():
    """找到 OBS 虚拟摄像头的 OpenCV 索引"""
    cams, obs_idx = scan_cameras()
    return obs_idx

def scan_audio_inputs():
    """扫描所有音频输入设备，返回 [(设备索引, 设备名), ...]"""
    import sounddevice as sd
    results = []
    for i, d in enumerate(sd.query_devices()):
        if d['max_input_channels'] > 0:
            results.append((i, d['name']))
    return results

# ═══════════════════════════════════════════════
# 换脸引擎
# ═══════════════════════════════════════════════

class FaceSwapper:
    """
    换脸引擎（CPU极致优化版）
    - 通过 ONNX SessionOptions 开启所有图优化 + 多线程
    - 使用超小 det_size 加速检测
    - 缓存所有模型到类变量（只加载一次）
    """
    _shared_app = None    # 类级别共享，所有实例共用
    _shared_swapper = None

    def __init__(self):
        self.backend = None
        self.app = None
        self.swapper = None
        self._init_backend()

    @classmethod
    def _optimize_all_sessions(cls):
        """修补 insightface 的 ONNX session，全局启用CPU极致优化"""
        import onnxruntime as ort
        import insightface.model_zoo.model_zoo as mz
        
        # 创建优化 session 选项
        opt = ort.SessionOptions()
        opt.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
        opt.enable_profiling = False
        opt.execution_mode = ort.ExecutionMode.ORT_PARALLEL
        opt.intra_op_num_threads = 6
        opt.inter_op_num_threads = 2
        
        # 修补 PickableInferenceSession 的 __init__
        original_init = mz.PickableInferenceSession.__init__
        
        def patched_init(self, model_path, **kwargs):
            if 'providers' not in kwargs:
                kwargs['providers'] = ['CPUExecutionProvider']
            kwargs['sess_options'] = opt
            original_init(self, model_path, **kwargs)
        
        mz.PickableInferenceSession.__init__ = patched_init
        return opt

    def _init_backend(self):
        if FaceSwapper._shared_app is not None and FaceSwapper._shared_swapper is not None:
            self.app = FaceSwapper._shared_app
            self.swapper = FaceSwapper._shared_swapper
            self.backend = "insightface"
            return

        try:
            import onnxruntime as ort
            from insightface.app import FaceAnalysis
            from insightface.model_zoo import get_model

            sess_opt = self._optimize_all_sessions()

            # 创建优化后的FaceAnalysis实例
            self.app = FaceAnalysis(
                name='buffalo_l',
                providers=['CPUExecutionProvider'],
            )
            # 最小检测尺寸
            self.app.prepare(ctx_id=-1, det_size=(160, 160))

            model_paths = [
                os.path.join(os.path.expanduser("~"), ".insightface", "models", "inswapper_128.onnx"),
                os.path.join(os.path.expanduser("~"), ".insightface", "models", "buffalo_l", "inswapper_128.onnx"),
            ]
            loaded = False
            for p in model_paths:
                if os.path.exists(p):
                    self.swapper = get_model(p)
                    loaded = True
                    break
            if not loaded:
                try:
                    self.swapper = get_model('inswapper_128.onnx')
                    loaded = True
                except:
                    pass
            if loaded:
                self.backend = "insightface"
                FaceSwapper._shared_app = self.app
                FaceSwapper._shared_swapper = self.swapper
                return
        except Exception as e:
            print(f"引擎初始化失败: {e}")
        self.backend = "manual"

    def get_source_faces(self, src_img):
        """提取并缓存源人脸
        只检测一次，之后复用结果"""
        try:
            old = self.app.det_size
            self.app.det_size = (160, 160)
            faces = self.app.get(src_img)
            self.app.det_size = old
            return faces
        except:
            return []

    def swap_faces(self, src_img, tgt_img):
        if self.backend == "insightface" and self.swapper is not None:
            try:
                sf = self.app.get(src_img)
                tf = self.app.get(tgt_img)
                if len(sf) == 0 or len(tf) == 0:
                    return tgt_img
                return self.swapper.get(tgt_img, tf[0], sf[0], paste_back=True)
            except:
                return tgt_img
        return self._swap_manual(src_img, tgt_img)

    def _swap_manual(self, src_img, tgt_img):
        try:
            fc = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
            sf = fc.detectMultiScale(cv2.cvtColor(src_img, cv2.COLOR_BGR2GRAY), 1.1, 5, minSize=(50, 50))
            tf = fc.detectMultiScale(cv2.cvtColor(tgt_img, cv2.COLOR_BGR2GRAY), 1.1, 5, minSize=(50, 50))
            if len(sf) == 0 or len(tf) == 0:
                return tgt_img
            sx, sy, sw, sh = sf[0]
            tx, ty, tw, th = tf[0]
            face = cv2.resize(src_img[sy:sy+sh, sx:sx+sw], (tw, th))
            mask = np.zeros((th, tw), dtype=np.float32)
            cv2.ellipse(mask, (tw//2, th//2), (tw//2-5, th//2-5), 0, 0, 360, 1.0, -1)
            mask = cv2.GaussianBlur(mask, (21, 21), 11)
            for c in range(3):
                tgt_img[ty:ty+th, tx:tx+tw, c] = (face[:,:,c] * mask + tgt_img[ty:ty+th, tx:tx+tw, c] * (1-mask)).astype(np.uint8)
            return tgt_img
        except:
            return tgt_img

    def swap_faces_live(self, src_img, tgt_img, det_size=None):
        if self.backend == "insightface" and self.swapper is not None:
            try:
                old = self.app.det_size
                if det_size:
                    self.app.det_size = det_size
                sf = self.app.get(src_img)
                tf = self.app.get(tgt_img)
                self.app.det_size = old
                if len(sf) == 0 or len(tf) == 0:
                    return tgt_img
                return self.swapper.get(tgt_img, tf[0], sf[0], paste_back=True)
            except:
                return tgt_img
        return self._swap_manual(src_img, tgt_img)

    def swap_with_face(self, tgt_img, src_face, tgt_face, det_size=None):
        """直接用现成人脸对象换脸（跳过检测）"""
        if self.backend == "insightface" and self.swapper is not None:
            try:
                return self.swapper.get(tgt_img, tgt_face, src_face, paste_back=True)
            except:
                return tgt_img
        return tgt_img

    @property
    def is_ready(self):
        return self.backend is not None

    def get_backend_name(self):
        return {"insightface": "InsightFace AI", "manual": "OpenCV手动"}.get(self.backend, "未知")

# ═══════════════════════════════════════════════
# 视频处理
# ═══════════════════════════════════════════════

class VideoProcessor:
    def __init__(self, swapper, src_img, in_path, out_path, progress_cb=None):
        self.swapper = swapper
        self.src_img = src_img
        self.in_path = in_path
        self.out_path = out_path
        self.progress_cb = progress_cb
        self._stop = False

    def stop(self):
        self._stop = True

    def run(self):
        cap = cv2.VideoCapture(self.in_path)
        if not cap.isOpened():
            raise RuntimeError("无法打开视频")
        fps = int(cap.get(cv2.CAP_PROP_FPS))
        w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        out = cv2.VideoWriter(self.out_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))
        if self.progress_cb:
            self.progress_cb(0, total, "处理中...")
        done = 0
        t0 = time.time()
        while True:
            if self._stop:
                break
            ret, frame = cap.read()
            if not ret:
                break
            out.write(self.swapper.swap_faces(self.src_img, frame))
            done += 1
            if self.progress_cb and done % max(1, total//20) == 0:
                e = time.time() - t0
                eta = (total-done)/(done/e) if done/e > 0 else 0
                self.progress_cb(done, total, f"{done}/{total} | FPS:{done/e:.1f} | 剩{int(eta//60)}分{int(eta%60)}秒")
        cap.release()
        out.release()
        if self.progress_cb:
            self.progress_cb(total, total, f"完成！耗时{int((time.time()-t0)//60)}分")
        return self.out_path

# ═══════════════════════════════════════════════
# 直播换脸
# ═══════════════════════════════════════════════

class LiveFaceSwap:
    """
    CPU极致优化版实时换脸
    
    核心策略：
    1. 超低分辨率捕获 (160x120) 减少 CPU 带宽
    2. 帧跳过 15:1（处理1帧跳过15帧）
    3. 检测 + 交换分离：每 15 帧才跑一次全检测
    4. 帧间使用 bbox 跟踪（不重新检测，直接用上次的人脸框）
    5. 扩展人脸区域 + alpha 羽化 → 覆盖头发区域
    6. ONNX 全优化 Session（多线程 + 图优化）
    """
    def __init__(self, swapper, src_img, src_faces, quality="fast", cam_index=0,
                 bg_changer=None, clothes_changer=None):
        self.swapper = swapper
        self.src_img = src_img
        self.src_faces = src_faces       # 预缓存的源人脸
        self.quality = quality
        self.cam_index = cam_index
        self.running = False
        self.cap = None
        self._stop_ev = threading.Event()
        self.fq = queue.Queue(maxsize=1)
        self.fps = 0
        self._fc = 0
        self._ft = time.time()
        self._latest = None
        self._raw_latest = None

        # 换衣 + 换背景
        self.bg_changer = bg_changer
        self.clothes_changer = clothes_changer

        # 缓存参数
        self._small_w, self._small_h = 160, 120  # 超低捕获分辨率
        self._det_size = (80, 80)                 # 最小检测尺寸

        # 帧调度
        self._skip_n = 15     # 跳15帧处理1帧
        self._track_n = 15    # 跟踪15帧后强制重检
        self._cnt = 0
        self._track_cnt = 0

        # 人脸追踪
        self._prev_face = None
        self._prev_bbox = None  # (x1,y1,x2,y2)

    def start(self):
        self.running = True
        self._stop_ev.clear()
        self.cap = cv2.VideoCapture(self.cam_index, cv2.CAP_DSHOW)
        if not self.cap.isOpened():
            raise RuntimeError(f"无法打开摄像头 (索引 {self.cam_index})")
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._small_w)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._small_h)
        self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        self.cap.set(cv2.CAP_PROP_FPS, 30)
        threading.Thread(target=self._capture, daemon=True).start()
        threading.Thread(target=self._process, daemon=True).start()

    def stop(self):
        self.running = False
        self._stop_ev.set()
        if self.cap:
            self.cap.release()
            self.cap = None

    def _capture(self):
        while not self._stop_ev.is_set() and self.cap:
            ret, f = self.cap.read()
            if not ret:
                break
            self._raw_latest = f
            self._cnt += 1
            # 帧跳过
            if self._skip_n > 0 and self._cnt % (self._skip_n + 1) != 0:
                continue
            if self.fq.full():
                try: self.fq.get_nowait()
                except: pass
            self.fq.put_nowait(f)

    def _process(self):
        while not self._stop_ev.is_set():
            try:
                f = self.fq.get(timeout=0.05)
            except queue.Empty:
                continue

            result = f.copy()
            if self.src_img is not None and len(self.src_faces) > 0:
                try:
                    self._track_cnt += 1
                    # 重检策略
                    if self._track_cnt >= self._track_n or self._prev_face is None:
                        self._track_cnt = 0
                        old_det = self.swapper.app.det_size
                        self.swapper.app.det_size = self._det_size
                        tf = self.swapper.app.get(f)
                        self.swapper.app.det_size = old_det
                        if len(tf) > 0:
                            self._prev_face = tf[0]
                            b = tf[0].bbox.astype(int)
                            self._prev_bbox = (max(0,b[0]), max(0,b[1]), min(f.shape[1],b[2]), min(f.shape[0],b[3]))

                    # 执行换脸
                    if self._prev_face is not None:
                        try:
                            swapped = self.swapper.swapper.get(
                                f, self._prev_face, self.src_faces[0], paste_back=True
                            )
                            # 头发区域羽化融合
                            if self._prev_bbox is not None:
                                result = self._blend_hair(f, swapped, self._prev_bbox)
                            else:
                                result = swapped
                        except:
                            pass
                except:
                    pass

            self._fc += 1
            now = time.time()
            if now - self._ft >= 1.0:
                self.fps = self._fc
                self._fc = 0
                self._ft = now
            # ── 换背景 ──
            if self.bg_changer is not None and self.bg_changer.ready:
                try:
                    result = self.bg_changer.change(result)
                except:
                    pass

            # ── 换衣 ──
            if self.clothes_changer is not None and self.clothes_changer.ready:
                try:
                    if self._prev_bbox is not None:
                        result = self.clothes_changer.change(result, self._prev_bbox)
                    else:
                        result = self.clothes_changer.change(result)
                except:
                    pass

            self._latest = result

    def _blend_hair(self, original, swapped, bbox):
        """
        人脸换脸后，将换脸区域扩展到头发区域并羽化融合
        使用距离渐变 mask + 高斯模糊
        """
        h, w = original.shape[:2]
        x1, y1, x2, y2 = bbox

        # 限制在图像范围内
        x1 = max(0, x1); y1 = max(0, y1)
        x2 = min(w, x2); y2 = min(h, y2)

        # 扩展 bbox 到头发区域（扩大 1.5 倍）
        fw = x2 - x1
        fh = y2 - y1
        cx = (x1 + x2) // 2
        cy = (y1 + y2) // 2

        # 向上扩展更多（额头→头发），两边和下面少一点
        ex1 = max(0, cx - int(fw * 0.9))
        ey1 = max(0, cy - int(fh * 1.0))   # 头顶多扩展
        ex2 = min(w, cx + int(fw * 0.9))
        ey2 = min(h, cy + int(fh * 0.8))

        # 创建羽化 mask
        mask = np.zeros((h, w), dtype=np.float32)
        mw = ex2 - ex1
        mh = ey2 - ey1
        if mw < 4 or mh < 4:
            return swapped

        # 渐变梯度 mask (中心区域 1.0，边缘 0.0)
        Y, X = np.ogrid[:mh, :mw]
        cy2, cx2 = mh//2, mw//2
        # 椭圆渐变
        dist = np.sqrt(((X - cx2) / (mw/2))**2 + ((Y - cy2) / (mh/2))**2)
        gradient = np.clip(1.0 - dist, 0.0, 1.0) ** 0.8
        mask[ey1:ey2, ex1:ex2] = gradient

        # 高斯模糊让边缘更平滑
        mask = cv2.GaussianBlur(mask, (7, 7), 2.0)

        # 融合
        mask_3c = np.stack([mask, mask, mask], axis=2)
        result = (swapped.astype(np.float32) * mask_3c +
                  original.astype(np.float32) * (1.0 - mask_3c))
        result = result.clip(0, 255).astype(np.uint8)
        return result

    def get_latest(self):
        if self._latest is not None:
            return self._latest
        return self._raw_latest

# ═══════════════════════════════════════════════
# 变声引擎
# ═══════════════════════════════════════════════

class VoiceChanger:
    def __init__(self, input_device=None):
        self.running = False
        self._stop_ev = threading.Event()
        self.sr = 16000
        self.chunk = 1024
        self.pitch = 0.0
        self.mix = 1.0
        self.audio_ok = False
        self.custom_input_dev = input_device
        try:
            import sounddevice as sd
            self.input_dev = input_device if input_device is not None else sd.default.device[0]
            self.output_dev = sd.default.device[1]
            self.audio_ok = len(sd.query_devices()) > 0
        except:
            pass

    def start(self):
        if not self.audio_ok:
            raise RuntimeError("无可用的音频设备")
        self.running = True
        self._stop_ev.clear()
        threading.Thread(target=self._loop, daemon=True).start()

    def stop(self):
        self.running = False
        self._stop_ev.set()

    def _loop(self):
        import sounddevice as sd
        import queue as qmod

        def cb(indata, outdata, frames, t, status):
            if self._stop_ev.is_set():
                outdata.fill(0)
                return
            a = indata[:, 0]
            if abs(self.pitch) > 0.1:
                a = self._shift(a, self.pitch)
            if self.mix < 1.0:
                a = a * self.mix + indata[:, 0] * (1.0 - self.mix)
            outdata[:, 0] = a
            if outdata.shape[1] > 1:
                outdata[:, 1] = a

        try:
            s = sd.Stream(device=(self.input_dev, self.output_dev),
                          samplerate=self.sr, blocksize=self.chunk, channels=1, callback=cb)
            s.start()
            while not self._stop_ev.is_set():
                time.sleep(0.1)
            s.stop()
            s.close()
        except:
            self._loop_fb()

    def _loop_fb(self):
        import sounddevice as sd
        import queue as qmod
        q = qmod.Queue()
        def icb(indata, f, t, s):
            q.put(indata.copy())
        si = sd.InputStream(device=self.input_dev, samplerate=self.sr, blocksize=self.chunk, channels=1, callback=icb)
        so = sd.OutputStream(device=self.output_dev, samplerate=self.sr, channels=1)
        si.start()
        so.start()
        while not self._stop_ev.is_set():
            try:
                d = q.get(timeout=0.1)
                a = d[:, 0]
                if abs(self.pitch) > 0.1:
                    a = self._shift(a, self.pitch)
                if self.mix < 1.0:
                    a = a * self.mix + d[:, 0] * (1.0 - self.mix)
                so.write(np.expand_dims(a, axis=1))
            except qmod.Empty:
                continue
        si.stop()
        so.stop()
        si.close()
        so.close()

    def _shift(self, audio, semitones):
        n = len(audio)
        if n < 32:
            return audio
        r = 2.0 ** (semitones / 12.0)
        if r > 1.0:
            idx = np.arange(0, n, r)
            idx = idx[idx < n].astype(np.int32)
            s = audio[idx]
        else:
            idx = np.arange(0, n-1+r, r)
            idx = idx[idx < n].astype(np.int32)
            s = audio[idx]
        if len(s) != n:
            s = np.interp(np.linspace(0, 1, n), np.linspace(0, 1, len(s)), s)
        mx = np.max(np.abs(s))
        if mx > 1.0:
            s = s / mx * 0.95
        return s

    def set_pitch(self, v):
        self.pitch = max(-12, min(12, v))

    def set_mix(self, v):
        self.mix = max(0.0, min(1.0, v))


# ═══════════════════════════════════════════════
# GUI
# ═══════════════════════════════════════════════

class FaceSwapGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("FaceSwap v2.2 — 换脸+变声+OBS")
        self.root.geometry("850x820")
        self.root.resizable(False, False)
        self.root.configure(bg="#2b2b2b")
        try:
            self.root.iconbitmap(default=os.path.join(APP_DIR, "icon.ico"))
        except:
            pass

        self.swapper = None
        self.status_var = tk.StringVar(value="初始化引擎...")

        # 视频换脸
        self.src_path = tk.StringVar()
        self.vid_path = tk.StringVar()
        self.out_path = tk.StringVar()
        self.processing = False
        self.processor = None

        # 直播
        self.live_src_path = tk.StringVar()
        self.live_src_img = None
        self.live_swapper = None
        self.live_running = False
        self.cam_source = tk.StringVar(value="native")  # "native" or "obs"

        # 变声
        self.vc = VoiceChanger()
        self.vc_running = False

        # 换衣 + 换背景
        self.bg_changer_obj = None
        self.clothes_changer_obj = None
        self.bg_img_cache = None
        self.cloth_img_cache = None

        self._build_ui()
        self.root.after(100, self._init_engine)

    # ───── 界面 ─────
    def _build_ui(self):
        style = ttk.Style()
        style.theme_use("clam")
        for w in ("TLabel", "TFrame", "TLabelframe", "TLabelframe.Label"):
            style.configure(w, background="#2b2b2b", foreground="white")
        style.configure("TButton", font=("微软雅黑", 10), padding=6)
        style.configure("TLabelframe.Label", font=("微软雅黑", 10, "bold"))
        style.map("TButton", background=[("active", "#404040")])
        style.configure("TNotebook", background="#2b2b2b", tabmargins=[2,5,2,0])
        style.configure("TNotebook.Tab", background="#3c3c3c", foreground="white", padding=[20,6], font=("微软雅黑",11,"bold"))
        style.map("TNotebook.Tab", background=[("selected", "#1a1a2e")])

        # 标题
        tf = tk.Frame(self.root, bg="#1a1a2e", height=55)
        tf.pack(fill=tk.X)
        tk.Label(tf, text="🎭 FaceSwap All-in-One", font=("微软雅黑", 16, "bold"),
                 bg="#1a1a2e", fg="#e0e0ff").pack(pady=6)
        self.eng_lbl = tk.Label(tf, text="引擎: 初始化中...", font=("微软雅黑", 9), bg="#1a1a2e", fg="#8888cc")
        self.eng_lbl.pack()

        nb = ttk.Notebook(self.root)
        nb.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        t1 = tk.Frame(nb, bg="#2b2b2b")
        nb.add(t1, text="🎬 视频换脸")
        self._build_video(t1)

        t2 = tk.Frame(nb, bg="#2b2b2b")
        nb.add(t2, text="📷 直播换脸+变声")
        self._build_live(t2)

        t3 = tk.Frame(nb, bg="#2b2b2b")
        nb.add(t3, text="💬 TikTok 私信")
        self._build_tiktok(t3)

    # ───── 视频换脸 ─────
    def _build_video(self, parent):
        pb = ttk.LabelFrame(parent, text="👤 预设人脸", padding=8)
        pb.pack(fill=tk.X, pady=5, padx=10)
        self.pf = tk.Frame(pb, bg="#2b2b2b")
        self.pf.pack()
        self._refresh_presets()

        ib = ttk.LabelFrame(parent, text="📷 输入参数", padding=12)
        ib.pack(fill=tk.X, pady=5, padx=10)
        for r, (l, v, cmd) in enumerate([
            ("源人脸图片:", self.src_path, self._browse_src),
            ("目标视频:", self.vid_path, self._browse_vid),
            ("输出视频:", self.out_path, self._browse_out),
        ]):
            tk.Label(ib, text=l, bg="#3c3c3c", fg="white").grid(row=r, column=0, sticky=tk.W, pady=5)
            tk.Entry(ib, textvariable=v, bg="#3c3c3c", fg="white", insertbackground="white", width=45).grid(row=r, column=1, padx=5)
            tk.Button(ib, text="浏览...", command=cmd, bg="#4a4a6a", fg="white", cursor="hand2").grid(row=r, column=2)

        pv = ttk.LabelFrame(parent, text="👁 预览", padding=8)
        pv.pack(fill=tk.BOTH, expand=True, pady=5, padx=10)
        self.pv_lbl = tk.Label(pv, bg="#1a1a1a", width=60, height=8, text="选择源图片后预览", fg="#666")
        self.pv_lbl.pack()

        pg = ttk.LabelFrame(parent, text="📊 进度", padding=8)
        pg.pack(fill=tk.X, pady=5, padx=10)
        self.pbar = ttk.Progressbar(pg, length=700, mode='determinate')
        self.pbar.pack(fill=tk.X)
        self.st_lbl = tk.Label(pg, textvariable=self.status_var, bg="#3c3c3c", fg="white", anchor=tk.W, height=1)
        self.st_lbl.pack(fill=tk.X, pady=5)

        bf = tk.Frame(parent, bg="#2b2b2b")
        bf.pack(fill=tk.X, padx=10, pady=10)
        self.start_btn = tk.Button(bf, text="🚀 开始换脸", command=self._start_proc,
            bg="#2d6a2d", fg="white", font=("微软雅黑", 12, "bold"), padx=20, pady=8, cursor="hand2")
        self.start_btn.pack(side=tk.LEFT, padx=5)
        self.stop_btn = tk.Button(bf, text="⏹ 停止", command=self._stop_proc,
            bg="#6a2d2d", fg="white", font=("微软雅黑", 10), padx=20, pady=8, state=tk.DISABLED, cursor="hand2")
        self.stop_btn.pack(side=tk.LEFT, padx=5)

    def _refresh_presets(self):
        for w in self.pf.winfo_children():
            w.destroy()
        presets = [(f, os.path.join(PRESET_DIR, f)) for f in os.listdir(PRESET_DIR)
                   if f.lower().endswith(('.jpg','.jpeg','.png','.webp','.bmp'))]
        if not presets:
            tk.Label(self.pf, text="（将图片放入 presets/ 文件夹）", bg="#2b2b2b", fg="#666", font=("微软雅黑",9)).pack()
        for name, path in presets:
            btn = tk.Button(self.pf, text=f"👤 {_preset_display_name(name)}", bg="#4a4a6a", fg="white",
                cursor="hand2", command=lambda p=path: self._load_preset(p), font=("微软雅黑",9), padx=10, pady=2)
            btn.pack(side=tk.LEFT, padx=4)

    def _load_preset(self, path):
        self.src_path.set(path)
        self._show_preview(path)

    # ───── 直播换脸+变声 ─────
    def _build_live(self, parent):
        cv = tk.Canvas(parent, bg="#2b2b2b", highlightthickness=0)
        sb = ttk.Scrollbar(parent, orient="vertical", command=cv.yview)
        sf = tk.Frame(cv, bg="#2b2b2b")
        sf.bind("<Configure>", lambda e: cv.configure(scrollregion=cv.bbox("all")))
        cv.create_window((0, 0), window=sf, anchor="nw", width=810)
        cv.configure(yscrollcommand=sb.set)
        cv.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        def _mw(ev):
            cv.yview_scroll(int(-1*(ev.delta/120)), "units")
        cv.bind_all("<MouseWheel>", _mw)

        # ─── ① 源图片 ───
        lb1 = ttk.LabelFrame(sf, text="📷 换脸源图片", padding=10)
        lb1.pack(fill=tk.X, pady=5, padx=10)
        self.lp_frame = tk.Frame(lb1, bg="#2b2b2b")
        self.lp_frame.pack(fill=tk.X, pady=2)
        self._refresh_live_presets()
        tk.Frame(lb1, bg="#3c3c3c", height=1).pack(fill=tk.X, pady=5)
        r1 = tk.Frame(lb1, bg="#2b2b2b")
        r1.pack(fill=tk.X)
        tk.Label(r1, text="或手动:", bg="#3c3c3c", fg="white").pack(side=tk.LEFT, padx=2)
        tk.Entry(r1, textvariable=self.live_src_path, bg="#3c3c3c", fg="white", insertbackground="white", width=38).pack(side=tk.LEFT, padx=5)
        tk.Button(r1, text="浏览...", command=self._browse_live_src, bg="#4a4a6a", fg="white", cursor="hand2").pack(side=tk.LEFT)
        self.live_src_st = tk.Label(lb1, text="请选择源图片", fg="#aaa", bg="#3c3c3c", font=("微软雅黑",9))
        self.live_src_st.pack(fill=tk.X, pady=2)

        # ─── ② 摄像头源选择 ───
        lb2 = ttk.LabelFrame(sf, text="🎥 摄像头源", padding=10)
        lb2.pack(fill=tk.X, pady=5, padx=10)

        cam_frame = tk.Frame(lb2, bg="#2b2b2b")
        cam_frame.pack(fill=tk.X, pady=3)

        # 原生摄像头
        rb_native = tk.Radiobutton(cam_frame, text="📷 电脑原生摄像头", variable=self.cam_source,
            value="native", bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
            activebackground="#2b2b2b", cursor="hand2", font=("微软雅黑", 11, "bold"))
        rb_native.pack(side=tk.LEFT, padx=20)

        # OBS
        rb_obs = tk.Radiobutton(cam_frame, text="🎬 OBS 虚拟摄像头", variable=self.cam_source,
            value="obs", bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
            activebackground="#2b2b2b", cursor="hand2", font=("微软雅黑", 11, "bold"))
        rb_obs.pack(side=tk.LEFT, padx=20)

        # OBS启动按钮
        self.obs_launch_btn = tk.Button(cam_frame, text="🚀 启动 OBS", command=self._launch_obs,
            bg="#4a4a6a", fg="white", font=("微软雅黑", 9), padx=8, pady=2, cursor="hand2")
        self.obs_launch_btn.pack(side=tk.LEFT, padx=10)

        # 摄像头状态
        self.cam_status_lbl = tk.Label(lb2, text="", fg="#aaa", bg="#3c3c3c", font=("微软雅黑", 9))
        self.cam_status_lbl.pack(fill=tk.X, pady=2)
        self._update_cam_status()

        # ─── ③ 摄像头控制和预览 ───
        lb3 = ttk.LabelFrame(sf, text="🎥 摄像头控制", padding=10)
        lb3.pack(fill=tk.X, pady=5, padx=10)

        c1 = tk.Frame(lb3, bg="#2b2b2b")
        c1.pack(fill=tk.X)
        tk.Label(c1, text="画质/速度:", bg="#3c3c3c", fg="white").pack(side=tk.LEFT)
        self.live_q = tk.StringVar(value="balanced")
        for v, t in [("fast","⚡极速"), ("balanced","⚖均衡"), ("quality","✨高质量")]:
            tk.Radiobutton(c1, text=t, variable=self.live_q, value=v, bg="#2b2b2b", fg="white",
                selectcolor="#3c3c3c", activebackground="#2b2b2b", cursor="hand2").pack(side=tk.LEFT, padx=8)
        self.live_fps_v = tk.StringVar(value="FPS: --")
        tk.Label(c1, textvariable=self.live_fps_v, bg="#1a1a2e", fg="#44ff44", font=("微软雅黑",12,"bold")).pack(side=tk.RIGHT, padx=10)

        c2 = tk.Frame(lb3, bg="#2b2b2b")
        c2.pack(fill=tk.X, pady=5)
        self.live_start_btn = tk.Button(c2, text="📷 开启直播换脸", command=self._start_live,
            bg="#2d6a2d", fg="white", font=("微软雅黑",12,"bold"), padx=20, pady=8, cursor="hand2")
        self.live_start_btn.pack(side=tk.LEFT, padx=5)
        self.live_stop_btn = tk.Button(c2, text="⏹ 关闭", command=self._stop_live,
            bg="#6a2d2d", fg="white", font=("微软雅黑",10), padx=20, pady=8, state=tk.DISABLED, cursor="hand2")
        self.live_stop_btn.pack(side=tk.LEFT, padx=5)
        self.live_st_lbl = tk.Label(c2, text="", bg="#2b2b2b", fg="#44ff44", font=("微软雅黑",9))
        self.live_st_lbl.pack(side=tk.LEFT, padx=15)

        # ─── ④ 换衣+换背景控制 ───
        fe = ttk.LabelFrame(sf, text="👗 换衣 + 🌄 换背景", padding=10)
        fe.pack(fill=tk.X, padx=10, pady=5)

        if not _HAS_FEATURES:
            tk.Label(fe, text="⚠️ 换衣/换背景模块未加载，请检查 face_swap_features.py",
                bg="#3c3c3c", fg="#ff6666", font=("微软雅黑",9)).pack()
        else:
            # ── 背景换 ──
            bg_frame = tk.Frame(fe, bg="#2b2b2b")
            bg_frame.pack(fill=tk.X, pady=3)
            self.bg_enabled = tk.BooleanVar(value=False)
            tk.Checkbutton(bg_frame, text="🌄 开启换背景", variable=self.bg_enabled,
                bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
                activebackground="#2b2b2b", cursor="hand2",
                font=("微软雅黑",11,"bold")).pack(side=tk.LEFT, padx=5)
            self.bg_path_var = tk.StringVar()
            tk.Entry(bg_frame, textvariable=self.bg_path_var, bg="#3c3c3c",
                fg="white", insertbackground="white", width=40,
                font=("微软雅黑",9)).pack(side=tk.LEFT, padx=5)
            tk.Button(bg_frame, text="选择背景图", command=self._browse_bg,
                bg="#4a4a6a", fg="white", cursor="hand2",
                font=("微软雅黑",9), padx=8, pady=1).pack(side=tk.LEFT, padx=3)
            self.bg_st_lbl = tk.Label(bg_frame, text="⛔ 未加载", fg="#aaa",
                bg="#3c3c3c", font=("微软雅黑",9))
            self.bg_st_lbl.pack(side=tk.LEFT, padx=8)

            # 背景色快捷预设
            bg_preset_frame = tk.Frame(fe, bg="#2b2b2b")
            bg_preset_frame.pack(fill=tk.X, pady=2, padx=20)
            tk.Label(bg_preset_frame, text="颜色预设:", bg="#2b2b2b", fg="#aaa",
                font=("微软雅黑",8)).pack(side=tk.LEFT, padx=2)
            for color_name, rgb in [
                ("🟦 天空蓝", (135,206,235)),
                ("🟩 绿幕", (0,255,0)),
                ("⬛ 纯黑", (0,0,0)),
                ("⬜ 纯白", (255,255,255)),
                ("🟪 渐变紫", (180,100,200)),
            ]:
                tk.Button(bg_preset_frame, text=color_name, 
                    command=lambda c=rgb: self._set_bg_color(c),
                    bg="#3c3c3c", fg="white", cursor="hand2",
                    font=("微软雅黑",8), padx=4, pady=0).pack(side=tk.LEFT, padx=2)

            # 分隔
            tk.Frame(fe, bg="#3c3c3c", height=1).pack(fill=tk.X, pady=4)

            # ── 换衣 ──
            cloth_frame = tk.Frame(fe, bg="#2b2b2b")
            cloth_frame.pack(fill=tk.X, pady=3)
            self.cloth_enabled = tk.BooleanVar(value=False)
            tk.Checkbutton(cloth_frame, text="👗 开启换衣", variable=self.cloth_enabled,
                bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
                activebackground="#2b2b2b", cursor="hand2",
                font=("微软雅黑",11,"bold")).pack(side=tk.LEFT, padx=5)
            self.cloth_path_var = tk.StringVar()
            tk.Entry(cloth_frame, textvariable=self.cloth_path_var, bg="#3c3c3c",
                fg="white", insertbackground="white", width=40,
                font=("微软雅黑",9)).pack(side=tk.LEFT, padx=5)
            tk.Button(cloth_frame, text="选择衣服图", command=self._browse_cloth,
                bg="#4a4a6a", fg="white", cursor="hand2",
                font=("微软雅黑",9), padx=8, pady=1).pack(side=tk.LEFT, padx=3)
            tk.Button(cloth_frame, text="📷 用源图", command=self._use_src_as_cloth,
                bg="#4a6a4a", fg="white", cursor="hand2",
                font=("微软雅黑",9), padx=8, pady=1).pack(side=tk.LEFT, padx=3)
            self.cloth_st_lbl = tk.Label(cloth_frame, text="⛔ 未加载", fg="#aaa",
                bg="#3c3c3c", font=("微软雅黑",9))
            self.cloth_st_lbl.pack(side=tk.LEFT, padx=8)

            # 换衣模式
            cloth_mode_frame = tk.Frame(fe, bg="#2b2b2b")
            cloth_mode_frame.pack(fill=tk.X, pady=2, padx=20)
            tk.Label(cloth_mode_frame, text="模式:", bg="#2b2b2b", fg="#aaa",
                font=("微软雅黑",8)).pack(side=tk.LEFT, padx=2)
            self.cloth_mode = tk.StringVar(value="pose")
            for v, t in [("pose", "姿势检测(精准)"), ("simple", "颜色迁移(稳妥)")]:
                tk.Radiobutton(cloth_mode_frame, text=t, variable=self.cloth_mode,
                    value=v, bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
                    activebackground="#2b2b2b", cursor="hand2",
                    font=("微软雅黑",8)).pack(side=tk.LEFT, padx=8)

            # 状态行
            self.fe_st_lbl = tk.Label(fe, text="", bg="#2b2b2b", fg="#aaa",
                font=("微软雅黑",9))
            self.fe_st_lbl.pack(fill=tk.X, pady=2)

        # ─── ⑤ 预览 ───
        pv2 = ttk.LabelFrame(sf, text="📺 实时预览", padding=5)
        pv2.pack(fill=tk.BOTH, padx=10, pady=5)
        self.live_cv = tk.Label(pv2, bg="#1a1a1a", width=640, height=360, text="启动摄像头后实时换脸效果", fg="#555")
        self.live_cv.pack()

        # ─── ⑤ 变声控制 ───
        vb = ttk.LabelFrame(sf, text="🎤 变声控制（可配合直播同时使用）", padding=10)
        vb.pack(fill=tk.X, padx=10, pady=5)

        # 音频源选择
        va_src = tk.Frame(vb, bg="#2b2b2b")
        va_src.pack(fill=tk.X, pady=3)
        self.audio_source = tk.StringVar(value="native")
        tk.Radiobutton(va_src, text="🎤 原生麦克风", variable=self.audio_source,
            value="native", bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
            activebackground="#2b2b2b", cursor="hand2", font=("微软雅黑",11,"bold"),
            command=self._on_audio_src_change).pack(side=tk.LEFT, padx=20)
        tk.Radiobutton(va_src, text="🎬 OBS Studio 音频", variable=self.audio_source,
            value="obs", bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
            activebackground="#2b2b2b", cursor="hand2", font=("微软雅黑",11,"bold"),
            command=self._on_audio_src_change).pack(side=tk.LEFT, padx=20)

        # 设备选择下拉（仅OBS模式显示）
        va_dev = tk.Frame(vb, bg="#2b2b2b")
        va_dev.pack(fill=tk.X, pady=2)
        self.audio_dev_label = tk.Label(va_dev, text="选择输入设备:", bg="#3c3c3c", fg="white", font=("微软雅黑",9))
        self.audio_dev_label.pack(side=tk.LEFT, padx=2)
        self.audio_devices_list = []  # [(idx, name), ...]
        self.audio_dev_var = tk.StringVar()
        self.audio_dev_combo = ttk.Combobox(va_dev, textvariable=self.audio_dev_var,
            state="readonly", width=50, font=("微软雅黑",9))
        self.audio_dev_combo.pack(side=tk.LEFT, padx=5)
        self.audio_dev_info = tk.Label(va_dev, text="", bg="#2b2b2b", fg="#888", font=("微软雅黑",9))
        self.audio_dev_info.pack(side=tk.LEFT, padx=5)
        # 默认隐藏设备选择
        self.audio_dev_label.pack_forget()
        self.audio_dev_combo.pack_forget()
        self.audio_dev_info.pack_forget()

        # 音调预设
        vp = tk.Frame(vb, bg="#2b2b2b")
        vp.pack(fill=tk.X, pady=3)
        for txt, val in [
            ("🔊 原声",0), ("👧 萝莉",4), ("🧒 正太",3),
            ("🤖 机器人",6), ("👨 大叔",-4), ("🧛 恶魔",-6), ("🐻 小熊",-8),
        ]:
            tk.Button(vp, text=txt, command=lambda v=val: self._apply_vp(v),
                bg="#4a4a6a", fg="white", cursor="hand2", font=("微软雅黑",9), padx=6, pady=2).pack(side=tk.LEFT, padx=2)

        vr1 = tk.Frame(vb, bg="#2b2b2b")
        vr1.pack(fill=tk.X, pady=5)
        tk.Label(vr1, text="音调:", bg="#3c3c3c", fg="white", width=8, anchor=tk.W).pack(side=tk.LEFT)
        self.pv = tk.DoubleVar(value=0)
        tk.Scale(vr1, from_=-12, to=12, orient=tk.HORIZONTAL, variable=self.pv, resolution=0.5,
            length=300, bg="#3c3c3c", fg="white", troughcolor="#1a1a2e",
            highlightbackground="#2b2b2b", command=self._on_pitch).pack(side=tk.LEFT)
        self.pl = tk.Label(vr1, text="0.0 半音", bg="#3c3c3c", fg="#44ff44", font=("微软雅黑",11,"bold"), width=12)
        self.pl.pack(side=tk.LEFT, padx=5)

        vr2 = tk.Frame(vb, bg="#2b2b2b")
        vr2.pack(fill=tk.X, pady=5)
        tk.Label(vr2, text="强度:", bg="#3c3c3c", fg="white", width=8, anchor=tk.W).pack(side=tk.LEFT)
        self.mv = tk.DoubleVar(value=1)
        tk.Scale(vr2, from_=0, to=1, orient=tk.HORIZONTAL, variable=self.mv, resolution=0.05,
            length=300, bg="#3c3c3c", fg="white", troughcolor="#1a1a2e",
            highlightbackground="#2b2b2b", command=self._on_mix).pack(side=tk.LEFT)
        self.ml = tk.Label(vr2, text="100%", bg="#3c3c3c", fg="#44ff44", font=("微软雅黑",11,"bold"), width=12)
        self.ml.pack(side=tk.LEFT, padx=5)

        vr3 = tk.Frame(vb, bg="#2b2b2b")
        vr3.pack(fill=tk.X, pady=5)
        self.vs_btn = tk.Button(vr3, text="🎤 开启变声", command=self._start_vc,
            bg="#2d6a2d", fg="white", font=("微软雅黑",12,"bold"), padx=20, pady=6, cursor="hand2")
        self.vs_btn.pack(side=tk.LEFT, padx=5)
        self.vx_btn = tk.Button(vr3, text="⏹ 停止变声", command=self._stop_vc,
            bg="#6a2d2d", fg="white", font=("微软雅黑",10), padx=20, pady=6, state=tk.DISABLED, cursor="hand2")
        self.vx_btn.pack(side=tk.LEFT, padx=5)
        self.vc_lbl = tk.Label(vr3, text="⏹ 待命", bg="#2b2b2b", fg="#aaa", font=("微软雅黑",9))
        self.vc_lbl.pack(side=tk.LEFT, padx=15)

        vlog = ttk.LabelFrame(sf, text="📋 变声日志", padding=3)
        vlog.pack(fill=tk.BOTH, padx=10, pady=5)
        self.vlog_txt = ScrolledText(vlog, height=3, bg="#111", fg="#0f0", insertbackground="white", font=("Consolas",9))
        self.vlog_txt.pack(fill=tk.BOTH, expand=True)
        self._vlog("变声引擎就绪。")

    def _refresh_live_presets(self):
        for w in self.lp_frame.winfo_children():
            w.destroy()
        tk.Label(self.lp_frame, text="预设:", bg="#2b2b2b", fg="white", font=("微软雅黑",9)).pack(side=tk.LEFT)
        presets = [(f, os.path.join(PRESET_DIR, f)) for f in os.listdir(PRESET_DIR)
                   if f.lower().endswith(('.jpg','.jpeg','.png','.webp','.bmp'))]
        if not presets:
            tk.Label(self.lp_frame, text="（无）", bg="#2b2b2b", fg="#666", font=("微软雅黑",9)).pack(side=tk.LEFT)
        for name, path in presets:
            btn = tk.Button(self.lp_frame, text=f"👤 {_preset_display_name(name)}", bg="#4a4a6a", fg="white",
                cursor="hand2", font=("微软雅黑",9), padx=8, pady=1,
                command=lambda p=path: self._load_live_preset(p))
            btn.pack(side=tk.LEFT, padx=3)

    def _load_live_preset(self, path):
        self.live_src_path.set(path)
        self.live_src_img = _safe_imread(path)
        if self.live_src_img is not None:
            self.live_src_st.config(text=f"已加载: {os.path.basename(path)}", fg="#44ff44")

    def _update_cam_status(self):
        """更新摄像头状态显示（不打开摄像头，只读名称）"""
        # 只用 pygrabber 读名称，不碰 cv2
        native_ok = True  # 默认有
        obs_ok = True     # 默认有，反正后面动态检测
        # 简单检测：尝试 pygrabber
        try:
            from pygrabber.dshow_graph import FilterGraph
            graph = FilterGraph()
            names = graph.get_input_devices()
            obs_ok = any('obs' in n.lower() or 'virtual' in n.lower() for n in names)
            native_ok = len(names) > 0
        except:
            pass
        status_parts = []
        status_parts.append("📷 原生摄像头: ✅" if native_ok else "📷 原生摄像头: ❌")
        status_parts.append("🎬 OBS: ✅" if obs_ok else "🎬 OBS: ❌")
        self.cam_status_lbl.config(text=" | ".join(status_parts))

    # ═══════════════════════════════════════════════
    #  TikTok 私信
    # ═══════════════════════════════════════════════
    def _build_tiktok(self, parent):
        from tiktok_bot import TikTokBot
        self.tt_bot = TikTokBot()
        self.tt_bot.set_on_log(self._tt_log)

        # ── ① 登录区 ──
        lg = ttk.LabelFrame(parent, text="🔐 TikTok 登录", padding=10)
        lg.pack(fill=tk.X, padx=10, pady=5)

        lf = tk.Frame(lg, bg="#2b2b2b")
        lf.pack()
        self.tt_btn = tk.Button(lf, text="🔑 扫码登录 TikTok", command=self._tt_login,
            bg="#4a6a4a", fg="white", font=("微软雅黑",12,"bold"), padx=20, pady=8, cursor="hand2")
        self.tt_btn.pack(side=tk.LEFT, padx=10)
        self.tt_st_lbl = tk.Label(lf, text="⛔ 未登录", bg="#2b2b2b", fg="#ff6666",
            font=("微软雅黑",11,"bold"))
        self.tt_st_lbl.pack(side=tk.LEFT, padx=15)

        # ── ② 自动回复控制 ──
        ar = ttk.LabelFrame(parent, text="🤖 自动回复设置", padding=10)
        ar.pack(fill=tk.X, padx=10, pady=5)

        ar1 = tk.Frame(ar, bg="#2b2b2b")
        ar1.pack(fill=tk.X, pady=3)
        self.tt_ar_var = tk.BooleanVar(value=False)
        self.tt_ar_btn = tk.Checkbutton(ar1, text="🔛 启用自动回复", variable=self.tt_ar_var,
            command=self._tt_toggle_auto, bg="#2b2b2b", fg="white", selectcolor="#3c3c3c",
            activebackground="#2b2b2b", cursor="hand2", font=("微软雅黑",11,"bold"))
        self.tt_ar_btn.pack(side=tk.LEFT)
        self.tt_ar_lbl = tk.Label(ar1, text="⏸ 已暂停", bg="#2b2b2b", fg="#888", font=("微软雅黑",10))
        self.tt_ar_lbl.pack(side=tk.LEFT, padx=15)

        self.tt_interval_var = tk.DoubleVar(value=5.0)
        tk.Label(ar1, text="检查间隔:", bg="#2b2b2b", fg="white", font=("微软雅黑",9)).pack(side=tk.LEFT, padx=5)
        tk.Spinbox(ar1, from_=1, to=60, increment=1, textvariable=self.tt_interval_var,
            width=5, font=("微软雅黑",9), bg="#3c3c3c", fg="white",
            buttonbackground="#3c3c3c").pack(side=tk.LEFT)
        tk.Label(ar1, text="秒", bg="#2b2b2b", fg="white", font=("微软雅黑",9)).pack(side=tk.LEFT)

        # ── ③ 回复规则 ──
        rr = tk.Frame(ar, bg="#2b2b2b")
        rr.pack(fill=tk.BOTH, pady=3)

        # 规则列表标题
        th = tk.Frame(rr, bg="#3c3c3c")
        th.pack(fill=tk.X)
        for txt, w in [("关键词", 150), ("匹配方式", 80), ("回复内容", 250)]:
            tk.Label(th, text=txt, bg="#3c3c3c", fg="#aaa", width=w//10,
                font=("微软雅黑",9), anchor=tk.W).pack(side=tk.LEFT, padx=2)

        # 规则输入区域
        self.tt_rules_frame = tk.Frame(rr, bg="#2b2b2b", height=100)
        self.tt_rules_frame.pack(fill=tk.X)
        self.tt_rules = []  # [{keyword, match, reply, vars}]
        self._tt_add_rule_row()  # 默认一行空规则

        # 添加/应用规则按钮
        rb = tk.Frame(ar, bg="#2b2b2b")
        rb.pack(fill=tk.X, pady=3)
        tk.Button(rb, text="➕ 添加规则", command=self._tt_add_rule_row,
            bg="#4a6a4a", fg="white", cursor="hand2", font=("微软雅黑",9), padx=10, pady=2).pack(side=tk.LEFT, padx=3)
        tk.Button(rb, text="💾 应用规则", command=self._tt_apply_rules,
            bg="#4a4a6a", fg="white", cursor="hand2", font=("微软雅黑",9), padx=10, pady=2).pack(side=tk.LEFT, padx=3)
        tk.Button(rb, text="🗑 清空规则", command=self._tt_clear_rules,
            bg="#6a4a4a", fg="white", cursor="hand2", font=("微软雅黑",9), padx=10, pady=2).pack(side=tk.LEFT, padx=3)

        # ── ④ 日志区 ──
        lg2 = ttk.LabelFrame(parent, text="📋 私信日志", padding=3)
        lg2.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
        self.tt_log_txt = ScrolledText(lg2, height=10, bg="#111", fg="#0f0",
            insertbackground="white", font=("Consolas",9))
        self.tt_log_txt.pack(fill=tk.BOTH, expand=True)
        self._tt_log("💬 TikTok 私信模块就绪")

    def _tt_login(self):
        """登录 TikTok"""
        self._tt_log("🔄 正在打开浏览器，请在浏览器中扫码登录...")
        self.tt_btn.config(state=tk.DISABLED, text="🔄 登录中...")
        # 在后台线程登录
        def _do():
            try:
                ok = self.tt_bot.login()
                self.root.after(0, lambda: self._tt_login_done(ok))
            except Exception as e:
                self.root.after(0, lambda: self._tt_login_done(False, str(e)))
        threading.Thread(target=_do, daemon=True).start()

    def _tt_login_done(self, ok: bool, err: str = ""):
        self.tt_btn.config(state=tk.NORMAL, text="🔑 扫码登录 TikTok")
        if ok:
            self.tt_st_lbl.config(text="✅ 已登录", fg="#44ff44")
            self._tt_log("✅ TikTok 登录成功")
            # 启动监控
            interval = self.tt_interval_var.get()
            self.tt_bot.start_monitoring(interval)
            self._tt_log(f"🔍 私信监控已启动 (间隔 {interval}s)")
        else:
            self.tt_st_lbl.config(text="⛔ 登录失败", fg="#ff6666")
            self._tt_log(f"❌ 登录失败: {err or '超时或取消'}")

    def _tt_toggle_auto(self):
        """切换自动回复"""
        enabled = self.tt_ar_var.get()
        self.tt_bot.set_auto_reply(enabled)
        if enabled:
            self.tt_ar_lbl.config(text="🟢 运行中", fg="#44ff44")
        else:
            self.tt_ar_lbl.config(text="⏸ 已暂停", fg="#888")

    def _tt_add_rule_row(self):
        """添加一行回复规则"""
        n = len(self.tt_rules)
        row = tk.Frame(self.tt_rules_frame, bg="#2b2b2b")
        row.pack(fill=tk.X, pady=1)

        kw = tk.Entry(row, width=18, font=("微软雅黑",9), bg="#3c3c3c", fg="white",
            insertbackground="white", relief=tk.FLAT)
        kw.pack(side=tk.LEFT, padx=2)
        kw.insert(0, "")

        match_var = tk.StringVar(value="contains")
        match_cb = ttk.Combobox(row, textvariable=match_var, values=["contains","exact","regex"],
            state="readonly", width=10, font=("微软雅黑",9))
        match_cb.pack(side=tk.LEFT, padx=2)

        reply = tk.Entry(row, width=35, font=("微软雅黑",9), bg="#3c3c3c", fg="white",
            insertbackground="white", relief=tk.FLAT)
        reply.pack(side=tk.LEFT, padx=2)
        reply.insert(0, "")

        del_btn = tk.Button(row, text="✕", command=lambda: self._tt_del_rule_row(row, n),
            bg="#6a3a3a", fg="white", cursor="hand2", font=("微软雅黑",8), padx=4, pady=0)
        del_btn.pack(side=tk.LEFT, padx=2)

        self.tt_rules.append({
            'frame': row,
            'keyword': kw,
            'match': match_cb,
            'match_var': match_var,
            'reply': reply,
            'del_btn': del_btn,
        })
        # 重新编号删除按钮
        self._tt_refresh_rows()

    def _tt_del_rule_row(self, frame, idx):
        """删除一行规则"""
        for r in self.tt_rules[:]:
            if r['frame'] == frame:
                frame.destroy()
                self.tt_rules.remove(r)
                break
        self._tt_refresh_rows()

    def _tt_refresh_rows(self):
        """刷新规则行编号"""
        for i, r in enumerate(self.tt_rules):
            pass  # 不需要编号，但保持整齐

    def _tt_apply_rules(self):
        """应用规则到 bot"""
        rules = []
        for r in self.tt_rules:
            kw = r['keyword'].get().strip()
            reply = r['reply'].get().strip()
            match = r['match_var'].get()
            if kw and reply:
                rules.append({'keyword': kw, 'reply': reply, 'match': match})
        self.tt_bot.set_reply_rules(rules)
        self._tt_log(f"📋 已应用 {len(rules)} 条回复规则")

    def _tt_clear_rules(self):
        """清空所有规则"""
        for r in self.tt_rules[:]:
            r['frame'].destroy()
        self.tt_rules.clear()
        self.tt_bot.set_reply_rules([])
        self._tt_log("🗑 已清空所有规则")

    def _tt_log(self, msg: str):
        """TikTok 日志"""
        if hasattr(self, 'tt_log_txt'):
            self.tt_log_txt.insert(tk.END, msg + "\n")
            self.tt_log_txt.see(tk.END)

    def _launch_obs(self):
        """启动 OBS Studio"""
        # 常见 OBS 安装路径
        obs_paths = [
            r"D:\obs-studio\bin\64bit\obs64.exe",
            r"C:\Program Files\obs-studio\bin\64bit\obs64.exe",
            r"C:\Program Files (x86)\obs-studio\bin\64bit\obs64.exe",
            r"C:\Program Files\OBS Studio\bin\64bit\obs64.exe",
            os.path.expanduser(r"~\AppData\Local\Programs\obs-studio\bin\64bit\obs64.exe"),
        ]
        found = None
        for p in obs_paths:
            if os.path.exists(p):
                found = p
                break
        if found:
            try:
                subprocess.Popen([found], shell=True)
                self._vlog(f"🚀 已启动 OBS Studio ({found})")
            except Exception as e:
                messagebox.showerror("错误", f"启动 OBS 失败:\n{e}")
        else:
            if messagebox.askyesno("未找到 OBS", "未在默认路径找到 OBS Studio，是否手动选择 obs64.exe？"):
                p = filedialog.askopenfilename(title="选择 obs64.exe", filetypes=[("可执行文件", "obs64.exe")])
                if p:
                    try:
                        subprocess.Popen([p, "--startvirtualcam"], shell=True)
                        self._vlog(f"🚀 已启动 OBS Studio ({p}) [自动开启虚拟摄像头]")
                    except Exception as e:
                        messagebox.showerror("错误", f"启动失败:\n{e}")

    # ───── 引擎 ─────
    def _init_engine(self):
        self.swapper = FaceSwapper()
        self.eng_lbl.config(text=f"引擎: {self.swapper.get_backend_name()}")

    # ───── 视频换脸回调 ─────
    def _browse_src(self):
        p = filedialog.askopenfilename(title="选择源人脸", filetypes=[("图片","*.jpg *.jpeg *.png *.bmp")])
        if p:
            self.src_path.set(p)
            self._show_preview(p)

    def _browse_vid(self):
        p = filedialog.askopenfilename(title="选择目标视频", filetypes=[("视频","*.mp4 *.avi *.mov *.mkv")])
        if p:
            self.vid_path.set(p)
            if not self.out_path.get():
                self.out_path.set(f"{os.path.splitext(p)[0]}_face_swapped.mp4")

    def _browse_out(self):
        p = filedialog.asksaveasfilename(title="输出路径", defaultextension=".mp4", filetypes=[("MP4","*.mp4")])
        if p:
            self.out_path.set(p)

    def _show_preview(self, src):
        try:
            img = _safe_imread(src)
            if img is not None:
                h, w = img.shape[:2]
                sc = min(180/w, 180/h)
                im = Image.fromarray(cv2.cvtColor(cv2.resize(img, (int(w*sc), int(h*sc))), cv2.COLOR_BGR2RGB))
                imgtk = ImageTk.PhotoImage(image=im)
                self.pv_lbl.config(image=imgtk, text="", bg="#1a1a1a")
                self.pv_lbl.image = imgtk
        except:
            pass

    def _start_proc(self):
        s, v, o = self.src_path.get(), self.vid_path.get(), self.out_path.get()
        if not all([s, v, o]):
            messagebox.showwarning("提示", "请选择源图片、目标视频和输出路径")
            return
        if not os.path.exists(s) or not os.path.exists(v):
            messagebox.showerror("错误", "文件不存在")
            return
        src_img = _safe_imread(s)
        if src_img is None:
            messagebox.showerror("错误", "无法读取源图片")
            return
        self.processing = True
        self.start_btn.config(state=tk.DISABLED, text="⏳ 处理中...")
        self.stop_btn.config(state=tk.NORMAL)
        self.pbar['value'] = 0
        self.status_var.set("处理中...")

        def cb(c, t, m):
            self.root.after(0, lambda: self.pbar.config(value=int(c/t*100) if t else 0))
            self.root.after(0, lambda: self.status_var.set(m))

        def proc():
            try:
                self.processor = VideoProcessor(self.swapper, src_img, v, o, cb)
                r = self.processor.run()
                self.root.after(0, lambda: self._done(r))
            except Exception as e:
                self.root.after(0, lambda: messagebox.showerror("错误", str(e)))
        threading.Thread(target=proc, daemon=True).start()

    def _stop_proc(self):
        if self.processor:
            self.processor.stop()
        self.processing = False
        self.start_btn.config(state=tk.NORMAL, text="🚀 开始换脸")
        self.stop_btn.config(state=tk.DISABLED)
        self.status_var.set("⏹ 已停止")

    def _done(self, path):
        self.processing = False
        self.start_btn.config(state=tk.NORMAL, text="🚀 开始换脸")
        self.stop_btn.config(state=tk.DISABLED)
        self.pbar['value'] = 100
        self.status_var.set("完成！")
        if messagebox.askyesno("完成", f"输出: {path}\n打开所在文件夹？"):
            os.startfile(os.path.dirname(path))

    # ───── 直播换脸回调 ─────
    def _browse_live_src(self):
        p = filedialog.askopenfilename(title="选择换脸源图片", filetypes=[("图片","*.jpg *.jpeg *.png *.bmp")])
        if p:
            self.live_src_path.set(p)
            self.live_src_img = _safe_imread(p)
            if self.live_src_img is None:
                messagebox.showerror("错误", "无法读取图片")
            else:
                self.live_src_st.config(text=f"已加载: {os.path.basename(p)}", fg="#44ff44")

    def _start_live(self):
        if self.live_src_img is None:
            messagebox.showwarning("提示", "请先选择换脸源图片")
            return
        if not self.swapper or not self.swapper.is_ready:
            messagebox.showerror("错误", "引擎未就绪")
            return

        # 确定摄像头索引
        if self.cam_source.get() == "native":
            cam_idx = 0
        else:
            obs_idx = find_obs_index()
            if obs_idx is None:
                self._vlog("🔄 OBS 未检测到，自动启动中...")
                self._launch_obs()
                self._vlog("⏳ 等待 OBS 就绪（5秒）...")
                self.root.after(5000, lambda: self._retry_obs())
                return
            cam_idx = obs_idx

        # 预提取源人脸（只做一次）
        src_faces = self.swapper.get_source_faces(self.live_src_img)
        if len(src_faces) == 0:
            messagebox.showerror("错误", "源图片中未检测到人脸，请换一张")
            return

        # 获取换衣+换背景实例
        bg_changer = self._get_bg_changer()
        clothes_changer = self._get_clothes_changer()

        # 更新状态显示
        feat_parts = []
        if bg_changer is not None:
            feat_parts.append("🌄")
        if clothes_changer is not None:
            feat_parts.append("👗")
        if feat_parts:
            self.fe_st_lbl.config(text=f"额外效果: {' '.join(feat_parts)}", fg="#44ff44")
        else:
            self.fe_st_lbl.config(text="", fg="#aaa")

        try:
            self.live_swapper = LiveFaceSwap(
                self.swapper, self.live_src_img, src_faces,
                self.live_q.get(), cam_idx,
                bg_changer=bg_changer,
                clothes_changer=clothes_changer
            )
            self.live_swapper.start()
            self.live_running = True
            self.live_start_btn.config(state=tk.DISABLED, text="📷 运行中")
            self.live_stop_btn.config(state=tk.NORMAL)
            self.live_st_lbl.config(text=f"🟢 运行中 ({'原生' if cam_idx==0 else 'OBS'})")
            self._update_live()
        except RuntimeError as e:
            messagebox.showerror("摄像头错误", 
                f"无法打开摄像头 (索引 {cam_idx})。\n\n"
                f"可能原因：\n"
                f"1. 摄像头被其他应用占用（关掉其他开摄像头的软件）\n"
                f"2. 摄像头驱动问题（重启电脑试试）\n"
                f"3. 你选的是OBS模式但OBS没开虚拟摄像头\n\n"
                f"详细错误:\n{e}")
        except Exception as e:
            messagebox.showerror("错误", f"启动失败:\n{e}")

    def _retry_obs(self):
        """重试检测 OBS，最多试 3 次"""
        obs_idx = find_obs_index()
        if obs_idx is not None:
            self._vlog(f"✅ OBS 虚拟摄像头已就绪 (索引 {obs_idx})")
            self._start_live()
            return
        # 还没好，再试一次
        self._vlog("⏳ OBS 未就绪，再等 3 秒重试...")
        self.root.after(3000, self._retry_obs2)

    def _retry_obs2(self):
        """第二次重试"""
        obs_idx = find_obs_index()
        if obs_idx is not None:
            self._vlog(f"✅ OBS 虚拟摄像头已就绪 (索引 {obs_idx})")
            self._start_live()
            return
        # 最终尝试启动虚拟摄像头
        self._vlog("🔄 尝试通过命令行启动 OBS 虚拟摄像头...")
        try:
            obs_paths = [
                r"D:\obs-studio\bin\64bit\obs64.exe",
                r"C:\Program Files\obs-studio\bin\64bit\obs64.exe",
            ]
            for p in obs_paths:
                if os.path.exists(p):
                    subprocess.Popen([p, "--startvirtualcam"], shell=True)
                    self._vlog(f"🚀 再次启动 OBS ({p})")
                    break
        except:
            pass
        # 再等 5 秒
        self._vlog("⏳ 等 5 秒后最终检测...")
        self.root.after(5000, self._retry_obs3)

    def _retry_obs3(self):
        """最终重试"""
        obs_idx = find_obs_index()
        if obs_idx is not None:
            self._vlog(f"✅ OBS 虚拟摄像头已就绪 (索引 {obs_idx})")
            self._start_live()
        else:
            messagebox.showwarning("提示",
                "OBS 虚拟摄像头仍未检测到。\n\n"
                "请确认：\n"
                "1. OBS Studio 已安装\n"
                "2. 工具 → 虚拟摄像头 → 已安装驱动\n"
                "3. 点击「启动 OBS」按钮手动启动后再试"
            )

    def _stop_live(self):
        if self.live_swapper:
            self.live_swapper.stop()
            self.live_swapper = None
        self.live_running = False
        self.live_start_btn.config(state=tk.NORMAL, text="📷 开启直播换脸")
        self.live_stop_btn.config(state=tk.DISABLED)
        self.live_st_lbl.config(text="")
        self.live_fps_v.set("FPS: --")
        self.fe_st_lbl.config(text="")

    def _update_live(self):
        if not self.live_running or not self.live_swapper:
            return
        f = self.live_swapper.get_latest()
        if f is not None:
            try:
                h, w = f.shape[:2]
                sc = min(640/w, 480/h)
                nw, nh = int(w*sc), int(h*sc)
                im = Image.fromarray(cv2.cvtColor(cv2.resize(f, (nw, nh)), cv2.COLOR_BGR2RGB))
                imgtk = ImageTk.PhotoImage(image=im)
                self.live_cv.config(image=imgtk, text="", bg="#1a1a1a")
                self.live_cv.image = imgtk
                ftext = f"FPS: {self.live_swapper.fps}"
                qtext = self.live_q.get().capitalize()
                self.live_fps_v.set(f"{ftext} | 模式: {qtext}")
            except:
                pass
        self.live_swapper.quality = self.live_q.get()
        self.root.after(50, self._update_live)  # 20fps UI更新

    # ───── 变声回调 ─────
    def _on_pitch(self, v):
        x = float(v)
        self.pl.config(text=f"{x:+.1f} 半音")
        self.vc.set_pitch(x)

    def _on_mix(self, v):
        x = float(v)
        self.ml.config(text=f"{int(x*100)}%")
        self.vc.set_mix(x)

    def _apply_vp(self, v):
        self.pv.set(v)
        self._on_pitch(v)
        self._vlog(f"预设变声: {v:+.1f} 半音")

    def _on_audio_src_change(self):
        """音频源切换"""
        if self.audio_source.get() == "native":
            self.audio_dev_label.pack_forget()
            self.audio_dev_combo.pack_forget()
            self.audio_dev_info.pack_forget()
        else:
            # 扫描输入设备
            devs = scan_audio_inputs()
            self.audio_devices_list = devs
            names = [f"[{i}] {n}" for i, n in devs]
            self.audio_dev_combo['values'] = names
            if names:
                # 默认选中第一个非默认的或OBS相关的
                obs_choice = None
                for i, (idx, name) in enumerate(devs):
                    nl = name.lower()
                    if 'obs' in nl or 'virtual' in nl or 'cable' in nl or 'mix' in nl:
                        obs_choice = i
                        break
                if obs_choice is None:
                    # 默认选第一个不是默认映射器的
                    for i, (idx, name) in enumerate(devs):
                        if '映射器' not in name:
                            obs_choice = i
                            break
                if obs_choice is None:
                    obs_choice = 0
                self.audio_dev_combo.current(obs_choice)
                self.audio_dev_var.set(names[obs_choice])
            self.audio_dev_label.pack(side=tk.LEFT, padx=2)
            self.audio_dev_combo.pack(side=tk.LEFT, padx=5)
            self.audio_dev_info.pack(side=tk.LEFT, padx=5)
            self.audio_dev_info.config(text=f"({len(devs)} 个输入设备)")

    def _get_audio_device(self):
        """获取当前选中的音频设备索引"""
        if self.audio_source.get() == "native":
            return None  # 使用默认
        sel = self.audio_dev_var.get()
        if not sel:
            return None
        # 从 "[3] OBS Virtual Camera" 中提取索引
        try:
            idx = int(sel.split(']')[0].lstrip('['))
            return idx
        except:
            return None

    def _start_vc(self):
        if not self.vc.audio_ok:
            messagebox.showerror("错误", "未检测到音频设备")
            return
        try:
            dev_idx = self._get_audio_device()
            source_name = "原生麦克风" if dev_idx is None else next((n for i,n in scan_audio_inputs() if i==dev_idx), f"设备[{dev_idx}]")
            self._vlog(f"音频源: {source_name}")

            # 用选择的设备重建 VoiceChanger
            self.vc.stop()
            self.vc = VoiceChanger(input_device=dev_idx)
            self.vc.set_pitch(self.pv.get())
            self.vc.set_mix(self.mv.get())
            self.vc.start()
            self.vc_running = True
            self.vs_btn.config(state=tk.DISABLED, text="🎤 变声中...")
            self.vx_btn.config(state=tk.NORMAL)
            self.vc_lbl.config(text="🟢 变声中", fg="#44ff44")
            self._vlog("🟢 变声已启动")
        except Exception as e:
            messagebox.showerror("错误", f"变声启动失败:\n{e}")

    def _stop_vc(self):
        self.vc.stop()
        self.vc_running = False
        self.vs_btn.config(state=tk.NORMAL, text="🎤 开启变声")
        self.vx_btn.config(state=tk.DISABLED)
        self.vc_lbl.config(text="⏹ 已停止", fg="#aaa")
        self._vlog("⏹ 变声已停止")

    def _vlog(self, msg):
        try:
            self.vlog_txt.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
            self.vlog_txt.see(tk.END)
        except:
            pass

    # ───── 换衣+换背景回调 ─────

    def _browse_bg(self):
        """浏览并加载背景图"""
        p = filedialog.askopenfilename(title="选择背景图片",
            filetypes=[("图片","*.jpg *.jpeg *.png *.bmp *.webp")])
        if p:
            self.bg_path_var.set(p)
            img = _safe_imread(p)
            if img is not None:
                self.bg_img_cache = img
                self.bg_st_lbl.config(text=f"✅ 已加载 ({os.path.basename(p)})", fg="#44ff44")
                self._vlog(f"🌄 背景图已加载: {p}")
            else:
                self.bg_st_lbl.config(text="❌ 读取失败", fg="#ff6666")

    def _set_bg_color(self, rgb):
        """用纯色作为背景"""
        self.bg_path_var.set(f"纯颜色 ({rgb[0]},{rgb[1]},{rgb[2]})")
        # 创建一个纯色背景图（先放小的，使用时 resize）
        self.bg_img_cache = np.full((480, 640, 3), list(rgb), dtype=np.uint8)
        self.bg_st_lbl.config(text=f"✅ 纯色 (RGB {rgb[0]},{rgb[1]},{rgb[2]})", fg="#44ff44")
        self._vlog(f"🌄 背景设置为纯色 RGB{rgb}")

    def _browse_cloth(self):
        """浏览并加载衣服源图"""
        p = filedialog.askopenfilename(title="选择衣服源图片",
            filetypes=[("图片","*.jpg *.jpeg *.png *.bmp *.webp")])
        if p:
            self.cloth_path_var.set(p)
            img = _safe_imread(p)
            if img is not None:
                self.cloth_img_cache = img
                self.cloth_st_lbl.config(text=f"✅ 已加载 ({os.path.basename(p)})", fg="#44ff44")
                self._vlog(f"👗 衣服源图已加载: {p}")
            else:
                self.cloth_st_lbl.config(text="❌ 读取失败", fg="#ff6666")

    def _use_src_as_cloth(self):
        """用换脸源图作为衣服参考"""
        if self.live_src_img is not None:
            self.cloth_img_cache = self.live_src_img.copy()
            self.cloth_path_var.set("(使用换脸源图)")
            self.cloth_st_lbl.config(text="✅ 使用换脸源图", fg="#44ff44")
            self._vlog("👗 衣服源 = 换脸源图")
        else:
            messagebox.showwarning("提示", "请先加载换脸源图片")

    def _get_bg_changer(self):
        """创建或更新背景更换器"""
        if not _HAS_FEATURES or not self.bg_enabled.get():
            return None
        if self.bg_img_cache is None:
            self._vlog("🌄 换背景已勾选但未加载背景图")
            return None
        if self.bg_changer_obj is None:
            self.bg_changer_obj = BackgroundChanger(self.bg_img_cache)
        else:
            self.bg_changer_obj.set_background(self.bg_img_cache)
        return self.bg_changer_obj

    def _get_clothes_changer(self):
        """创建或更新换衣器"""
        if not _HAS_FEATURES or not self.cloth_enabled.get():
            return None
        if self.cloth_img_cache is None:
            self._vlog("👗 换衣已勾选但未加载衣服图")
            return None
        mode = self.cloth_mode.get()
        if mode == "pose":
            if not hasattr(self, '_cloth_changer_main') or self._cloth_changer_main is None:
                self._cloth_changer_main = ClothesChanger(self.cloth_img_cache)
            else:
                self._cloth_changer_main.set_source_clothes(self.cloth_img_cache)
            return self._cloth_changer_main
        else:
            if not hasattr(self, '_cloth_changer_simple') or self._cloth_changer_simple is None:
                self._cloth_changer_simple = SimpleClothesChanger(self.cloth_img_cache)
            else:
                self._cloth_changer_simple.set_source_clothes(self.cloth_img_cache)
            return self._cloth_changer_simple


# ═══════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════

def main():
    root = tk.Tk()
    app = FaceSwapGUI(root)
    root.mainloop()


if __name__ == "__main__":
    main()
