吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1418|回复: 23
收起左侧

[Python 原创] 视频增强小工具 - 基于AI模型

  [复制链接]
psqladm 发表于 2026-8-26 20:01
本帖最后由 psqladm 于 2026-9-2 06:29 编辑

视频增强小工具 - 基于AI模型


运行界面如图:





效果对比图:

增强后(4X)



一、运行依赖:
1、FFMPEG.exe, FFPROBE.exe;
2、realesrgan-ncnn-vulkan开源模型。
     https://github.com/xinntao/Real-ESRGAN



二、工作流程:
ffmpeg拆分成单帧 ---->预处理(去噪、去伪影)---->AI模型增强----> 精调(色彩、锐化)---->合成视频(无音频)---->抽取原输入文件音频---->合成完整视频
修改为:
ffmpeg预处理(去噪、去伪影、拆分成单帧)---->AI模型增强---->合成视频(无音频)----> 精调(色彩、锐化)---->抽取原输入文件音频---->调整音量、合成完整视频


流程再造的目的是大削减文件I/O的吞吐量,每个进程每次划分512张图片的chunk,轮作,优化显存利用,避免程序崩溃。

工作量比较大,尤其是AI模型增强部分,虽然采取了多进程,比较耗时,做好心理准备。

提醒:
1、三个模型,使用缺省模型,适应大多数情况。其他模型,精度提高的同时,耗时成倍增长。
2、参数调节部分,使用缺省值,适应大多数情况,谨慎调整,除非明白调整后的效果。
3、AI增强使用CPU还是GPU,由模型自行选择,参数为"-g", "auto",方便只有集成显卡的,也可以跑通程序。有独立显卡的, 也可以自己指定如"-g", "0"。
4、视频文件最好放在固态硬盘上,因为I/O吞吐量实在太大,放在机械硬盘上,严重限制速度。
5、应坛友要求,加入音量调节功能,但是音量超过1.5X,有可能会爆音或失真。

感谢那些提供思路和意见,以及帮助的坛友!

成品软件、模型文件及ffmpeg见链接,模型文夹件及ffmpeg文件夹,请放在C盘根目录下。

通过网盘分享的文件:视频增强工具
链接: https://pan.baidu.com/s/18s6j-BU2CEqfJWJimW74eA?pwd=52pj 提取码: 52pj


[Python] 纯文本查看 复制代码
# -*- coding=utf-8 -*-

import json
import os
import shutil
import signal
import subprocess
import sys
import threading
import time
from collections import deque
from queue import Queue, Empty

from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
    QPushButton, QListWidget, QFileDialog, QGroupBox, QCheckBox, QLabel,
    QProgressBar, QPlainTextEdit, QLineEdit, QMessageBox, QComboBox,
    QSlider, QFormLayout,
)

# 路径配置
REALESRGAN_PATH = r"c:\realesrgan-ncnn-vulkan\realesrgan-ncnn-vulkan.exe"
FFMPEG_PATH = r"C:\ffmpeg\bin\ffmpeg.exe"
FFPROBE_PATH = r"C:\ffmpeg\bin\ffprobe.exe"
DEFAULT_OUTPUT_DIR = r"C:\Restored_Videos"

# 模型
MODELS = [
    ("realesr-animevideov3", "RealESRGAN AnimeVideoV3"),
    ("realesrgan-x4plus", "RealESRGAN x4+"),
    ("realesrgan-x4plus-anime", "RealESRGAN x4+ Anime"),
]

# 参数
DEFAULT_POST_PARAMS = {
    "dn_spatial": 4, "dn_temporal": 3,
    "deblock_mode": "strong", "deblock_block": 8,
    "contrast": 1.08, "saturation": 1.15, "brightness": 0.02,
    "sharp_luma": 1.5, "sharp_chroma": 1.0, "volume": 1.0,
}

CHUNK_SIZE = 512 # 分块大小
N_WORKERS = max(1, (os.cpu_count() or 4) // 4) # 并行进程数


def _resolve_tool(path):
    if not path: return ""
    for p in (path, path + ".exe"):
        if os.path.exists(p): return p
    return shutil.which(os.path.basename(path)) or path


FFMPEG_PATH = _resolve_tool(FFMPEG_PATH)
FFPROBE_PATH = _resolve_tool(FFPROBE_PATH)
REALESRGAN_PATH = _resolve_tool(REALESRGAN_PATH)


def startupinfo():
    if os.name == "nt":
        si = subprocess.STARTUPINFO()
        si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        si.wShowWindow = 0
        return si
    return None


class UtilityWorker(QThread):
    log_signal = pyqtSignal(str)
    finished_signal = pyqtSignal(bool, str)

    def __init__(self, func, parent=None):
        super().__init__(parent)
        self.func = func

    def run(self):
        try:
            msg = self.func() or "完成"
            self.log_signal.emit(str(msg))
            self.finished_signal.emit(True, str(msg))
        except Exception as e:
            self.log_signal.emit(f"❌ {e}")
            self.finished_signal.emit(False, str(e))


class WorkerThread(QThread):
    progress_signal = pyqtSignal(int, str)
    log_signal = pyqtSignal(str)
    finished_signal = pyqtSignal(bool, str)

    def __init__(self, file_list, output_dir, scale, model, options, post_params=None):
        super().__init__()
        self.file_list = list(file_list)
        self.output_dir = output_dir
        self.scale = max(1, int(scale))
        self.model = model or ""
        self.options = options or {}
        self.post_params = post_params or dict(DEFAULT_POST_PARAMS)
        self._stop = threading.Event()
        self._procs = []
        self._lock = threading.Lock()
        self._finished_emitted = False

    def _emit_finished(self, ok, msg):
        if self._finished_emitted: return
        self._finished_emitted = True
        self.finished_signal.emit(ok, msg)

    def stop(self):
        if self._stop.is_set(): return
        self._stop.set()
        self.log_signal.emit("⚠️ 正在终止所有子进程...")
        self._kill_all(wait=True)

    def _launch(self, cmd, capture=False):
        kwargs = {}
        if os.name == "nt":
            si = startupinfo()
            if si is not None: kwargs["startupinfo"] = si
        else:
            kwargs["preexec_fn"] = os.setsid
        if capture:
            proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                    text=True, encoding="utf-8", errors="ignore", **kwargs)
        else:
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
        with self._lock:
            self._procs.append(proc)
        return proc

    def _untrack(self, proc):
        with self._lock:
            if proc in self._procs: self._procs.remove(proc)

    def _kill_proc(self, proc, wait=True):
        if proc is None or proc.poll() is not None: return
        try:
            if os.name == "nt":
                subprocess.Popen(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
        except Exception:
            try:
                proc.terminate()
            except Exception:
                pass
        if not wait: return
        try:
            proc.wait(timeout=2)
        except subprocess.TimeoutExpired:
            try:
                proc.kill()
                proc.wait(timeout=2)
            except Exception:
                pass

    def _kill_all(self, wait=True):
        with self._lock:
            procs = list(self._procs)
        for p in procs: self._kill_proc(p, wait=False)
        if wait:
            for p in procs:
                try:
                    p.wait(timeout=3)
                except subprocess.TimeoutExpired:
                    self._kill_proc(p, wait=True)
        with self._lock:
            self._procs.clear()

    @staticmethod
    def _list_png(directory):
        if not os.path.isdir(directory): return []
        try:
            return sorted(f for f in os.listdir(directory) if f.lower().endswith(".png"))
        except OSError:
            return []

    @staticmethod
    def _count_png(directory):
        if not os.path.isdir(directory): return 0
        try:
            return sum(1 for e in os.scandir(directory) if e.is_file() and e.name.lower().endswith(".png"))
        except OSError:
            return 0

    def get_video_info(self, path):
        fps, w, h, nf = 30.0, 0, 0, 0
        try:
            out = subprocess.check_output(
                [FFPROBE_PATH, "-v", "error", "-select_streams", "v:0",
                 "-show_entries", "stream=r_frame_rate,width,height,nb_frames",
                 "-show_entries", "format=duration", "-of", "json", path],
                stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="ignore", timeout=30)
            d = json.loads(out)
            st = (d.get("streams") or [{}])[0]
            fr = str(st.get("r_frame_rate") or st.get("avg_frame_rate") or "30/1")
            if "/" in fr:
                n, dn = fr.split("/")
                if float(dn) != 0: fps = round(float(n) / float(dn), 3)
            else:
                try:
                    fps = float(fr)
                except Exception:
                    pass
            if fps <= 0: fps = 30.0
            w = int(st.get("width") or 0)
            h = int(st.get("height") or 0)
            nb = st.get("nb_frames")
            if nb and nb != "N/A":
                nf = int(nb)
            else:
                dur = float(d.get("format", {}).get("duration", 0) or 0)
                nf = int(dur * fps) if dur > 0 else 0
        except Exception as e:
            self.log_signal.emit(f"&#9888;&#65039; 获取视频信息失败: {e}")
        return fps, w, h, nf

    def _has_audio(self, path):
        try:
            out = subprocess.check_output(
                [FFPROBE_PATH, "-v", "error", "-select_streams", "a",
                 "-show_entries", "stream=index", "-of", "csv=p=0", path],
                stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="ignore", timeout=30)
            return bool(out.strip())
        except Exception:
            return False

    def _build_pre_filters(self):
        p = self.post_params
        vf_parts = []
        if self.options.get("denoise"):
            s, t = float(p.get("dn_spatial", 4)), float(p.get("dn_temporal", 3))
            vf_parts.append(f"hqdn3d={s:g}:{s:g}:{t:g}:{t:g}")
        if self.options.get("artifact"):
            mode = str(p.get("deblock_mode", "strong")).lower()
            if mode not in ("weak", "medium", "strong"): mode = "strong"
            block = min((4, 8, 16), key=lambda x: abs(x - int(p.get("deblock_block", 8))))
            vf_parts.append(f"deblock=filter={mode}:block={block}")
        return ",".join(vf_parts) if vf_parts else ""

    def _build_post_filters(self):
        p = self.post_params
        vf_parts = []
        if self.options.get("color"):
            c = float(p.get("contrast", 1.08))
            s = float(p.get("saturation", 1.15))
            b = float(p.get("brightness", 0.02))
            vf_parts.append(f"eq=contrast={c:.2f}:saturation={s:.2f}:brightness={b:.2f}")
        if self.options.get("sharpen"):
            sl = float(p.get("sharp_luma", 1.5))
            sc = float(p.get("sharp_chroma", 1.0))
            vf_parts.append(f"unsharp=5:5:{sl:.1f}:5:5:{sc:.1f}")
        return ",".join(vf_parts) if vf_parts else ""

    def _run_cmd(self, cmd, silent=False, out_dir=None, total=0, label=""):
        proc = self._launch(cmd, capture=True)
        q = Queue(maxsize=2048)

        def reader():
            try:
                for line in proc.stdout: q.put(line)
            except Exception:
                pass
            finally:
                q.put(None)

        reader_thread = threading.Thread(target=reader, daemon=True)
        reader_thread.start()
        lines = deque(maxlen=100)
        last_progress_time = 0.0

        try:
            while True:
                if self._stop.is_set(): raise InterruptedError("用户停止")
                if out_dir and total > 0 and time.time() - last_progress_time >= 0.5:
                    cnt = self._count_png(out_dir)
                    pct = min(99, int(cnt / max(total, 1) * 100))
                    self.progress_signal.emit(pct, f"{label}: {cnt}/{total} ({pct}%)")
                    last_progress_time = time.time()
                try:
                    line = q.get(timeout=0.2)
                except Empty:
                    if proc.poll() is not None: break
                    continue
                if line is None: break
                txt = line.strip()
                if txt and not silent: lines.append(txt)
            reader_thread.join(timeout=2)
            rc = proc.wait()
            if self._stop.is_set(): raise InterruptedError("用户停止")
            return rc, "\n".join(lines)
        except InterruptedError:
            self._kill_proc(proc, wait=True);
            raise
        except Exception:
            self._kill_proc(proc, wait=True);
            raise
        finally:
            self._untrack(proc)

    #  分块
    def _split_into_chunks(self, src_dir):
        files = self._list_png(src_dir)
        total = len(files)
        if total == 0:
            return []

        chunks_root = os.path.join(os.path.dirname(src_dir), "chunks")
        os.makedirs(chunks_root, exist_ok=True)

        chunk_dirs = []
        for i in range(0, total, CHUNK_SIZE):
            idx = i // CHUNK_SIZE
            dst = os.path.join(chunks_root, f"chunk_{idx:04d}")
            os.makedirs(dst, exist_ok=True)

            for f in files[i:min(i + CHUNK_SIZE, total)]:
                src_path = os.path.join(src_dir, f)
                dst_path = os.path.join(dst, f)
                try:
                    os.rename(src_path, dst_path)
                except OSError:
                    shutil.move(src_path, dst_path)

            chunk_dirs.append(dst)

        return chunk_dirs

    #  AI增强
    def _run_ai_chunks(self, work_dir, chunk_dirs, ai_seq_dir, out_w, out_h, tile):
        total_frames = sum(self._count_png(d) for d in chunk_dirs)
        if total_frames == 0:
            raise RuntimeError("AI 输入帧为空")

        self.log_signal.emit(
            f"&#128640; AI修复: {len(chunk_dirs)}块 | {total_frames}帧 | "
            f"模型:{self.model} | Tile={tile}"
        )

        max_parallel = min(N_WORKERS, len(chunk_dirs))
        running = []
        next_idx = 0
        t0 = time.time()
        last_progress_time = 0.0
        PROGRESS_INTERVAL = 0.5

        while next_idx < len(chunk_dirs) or running:
            while len(running) < max_parallel and next_idx < len(chunk_dirs):
                cin = chunk_dirs[next_idx]
                cmd = [
                    REALESRGAN_PATH, "-i", cin, "-o", ai_seq_dir,
                    "-n", self.model, "-t", str(tile),
                    "-j", "1:2:2", "-f", "png", "-g", "auto"
                ]
                if self.scale > 1:
                    cmd += ["-s", str(self.scale)]

                proc = self._launch(cmd, capture=False)
                running.append((next_idx, proc, cin))
                self.log_signal.emit(
                    f"   &#9654; Chunk {next_idx + 1}/{len(chunk_dirs)} PID:{proc.pid}"
                )
                next_idx += 1

            time.sleep(0.2 if running else 0.05)

            still = []
            fail_msg = None
            for idx, proc, chunk_path in running:
                if proc.poll() is not None:
                    rc = proc.returncode
                    self._untrack(proc)

                    if rc != 0:
                        fail_msg = f"Chunk {idx + 1} 失败(退出码{rc})"
                        break

                    try:
                        shutil.rmtree(chunk_path, ignore_errors=True)
                    except Exception as e:
                        self.log_signal.emit(f"   &#9888;&#65039; 清理chunk {idx + 1}失败: {e}")

                    self.log_signal.emit(
                        f"   &#9989; Chunk {idx + 1}/{len(chunk_dirs)} 完成并已清理"
                    )
                else:
                    still.append((idx, proc, chunk_path))

            if fail_msg:
                raise RuntimeError(fail_msg)

            running = still

            now = time.time()
            if now - last_progress_time >= PROGRESS_INTERVAL or not running:
                done = self._count_png(ai_seq_dir)
                done_safe = min(done, total_frames)
                pct = min(99, int(done_safe / max(total_frames, 1) * 100))

                elapsed = max(now - t0, 0.01)
                speed = done_safe / elapsed
                remaining = total_frames - done_safe
                eta = remaining / max(speed, 0.001) if speed > 0 else 0

                self.progress_signal.emit(
                    pct,
                    f"AI增强: {done_safe}/{total_frames}帧 ({pct}%) | "
                    f"{speed:.1f}fps | ETA {int(eta)}s"
                )
                last_progress_time = now

        cost = time.time() - t0
        final_done = min(self._count_png(ai_seq_dir), total_frames)
        self.log_signal.emit(
            f"&#9989; AI完成: {final_done}/{total_frames}帧 | {cost:.1f}s | "
            f"{final_done / max(cost, 0.01):.1f}fps"
        )

    def run(self):
        total_videos = len(self.file_list)
        need_ai = bool(self.options.get("ai"))
        need_pre = bool(self.options.get("denoise") or self.options.get("artifact"))

        ai_scale = self.scale if (need_ai and self.scale > 1) else (2 if need_ai else 1)
        eff_scale = ai_scale

        steps = ["拆帧" + ("+预处理" if need_pre else "")]
        if need_ai:
            steps.append("AI 修复")
        steps.append("合成与增强")

        try:
            os.makedirs(self.output_dir, exist_ok=True)
        except Exception as e:
            self._emit_finished(False, f"输出目录不可写: {e}")
            return

        for vid_idx, path in enumerate(self.file_list):
            if self._stop.is_set():
                break

            base = os.path.basename(path)
            self.log_signal.emit(f"&#127916; [{vid_idx + 1}/{total_videos}] {base}")

            if not os.path.exists(path):
                self._emit_finished(False, f"{base} 不存在")
                return

            work_dir = os.path.join(
                self.output_dir,
                f"_work_{vid_idx}_{os.getpid()}_{int(time.time())}"
            )

            try:
                fps, w, h, total_frames = self.get_video_info(path)
                if w <= 0 or h <= 0:
                    raise RuntimeError("无法获取视频分辨率")
                total_frames = int(max(0, total_frames))
                out_w, out_h = w * eff_scale, h * eff_scale

                #  Step 1: 拆帧
                step_idx = 1
                self.progress_signal.emit(0, f"Step {step_idx}/{len(steps)}: {steps[0]}...")

                safe_dir = os.path.join(work_dir, "safe")
                os.makedirs(safe_dir, exist_ok=True)

                cmd_extract = [FFMPEG_PATH, "-y", "-i", path]
                pre_vf = self._build_pre_filters()
                if pre_vf:
                    cmd_extract += ["-vf", pre_vf]
                    self.log_signal.emit(f"&#127912; 预处理滤镜: {pre_vf}")

                cmd_extract += [
                    "-pix_fmt", "rgb24", "-compression_level", "1",
                    os.path.join(safe_dir, "frame_%08d.png")
                ]

                ret, _ = self._run_cmd(
                    cmd_extract, out_dir=safe_dir,
                    total=total_frames, label="拆帧"
                )
                if ret != 0:
                    raise RuntimeError("拆帧失败")

                actual_frames = self._count_png(safe_dir)
                if actual_frames == 0:
                    raise RuntimeError("未提取到帧")
                self.log_signal.emit(f"&#9989; 拆帧完成: {actual_frames} 帧")

                chunk_dirs = self._split_into_chunks(safe_dir)
                self.log_signal.emit(f"&#128256; 已切分为 {len(chunk_dirs)} 个块")

                shutil.rmtree(safe_dir, ignore_errors=True)

                #  Step 2: AI增强
                ai_seq_dir = os.path.join(work_dir, "ai_seq")
                os.makedirs(ai_seq_dir, exist_ok=True)

                if need_ai:
                    step_idx += 1
                    self.progress_signal.emit(
                        0, f"Step {step_idx}/{len(steps)}: AI 修复..."
                    )
                    self._run_ai_chunks(
                        work_dir, chunk_dirs, ai_seq_dir, out_w, out_h, tile=0
                    )
                    shutil.rmtree(
                        os.path.join(work_dir, "chunks"), ignore_errors=True
                    )
                else:
                    step_idx += 1
                    self.progress_signal.emit(
                        0, f"Step {step_idx}/{len(steps)}: 整理帧序列..."
                    )
                    for cd in chunk_dirs:
                        for f in self._list_png(cd):
                            src = os.path.join(cd, f)
                            dst = os.path.join(ai_seq_dir, f)
                            try:
                                os.rename(src, dst)
                            except OSError:
                                shutil.move(src, dst)
                        shutil.rmtree(cd, ignore_errors=True)

                    shutil.rmtree(
                        os.path.join(work_dir, "chunks"), ignore_errors=True
                    )
                    self.log_signal.emit(
                        f"&#9989; 帧序列就绪: {self._count_png(ai_seq_dir)} 帧"
                    )

                #  Step 3: 合成与增强
                step_idx += 1
                self.progress_signal.emit(
                    0, f"Step {step_idx}/{len(steps)}: 合成与增强..."
                )

                out_mp4 = os.path.join(
                    self.output_dir,
                    os.path.splitext(base)[0] + "_restored.mp4"
                )
                has_audio = self._has_audio(path)

                cmd_merge = [
                    FFMPEG_PATH, "-y", "-framerate", str(fps),
                    "-i", os.path.join(ai_seq_dir, "frame_%08d.png")
                ]

                if has_audio:
                    cmd_merge += ["-i", path]
                    vol = max(
                        0.0, min(3.0, float(self.post_params.get("volume", 1.0)))
                    )
                    if abs(vol - 1.0) > 0.01:
                        cmd_merge += [
                            "-filter_complex",
                            f"[1:a]volume={vol:.2f}[aout]",
                            "-map", "0:v:0", "-map", "[aout]"
                        ]
                    else:
                        cmd_merge += [
                            "-map", "0:v:0", "-map", "1:a:0", "-c:a", "copy"
                        ]
                else:
                    cmd_merge += ["-map", "0:v:0"]

                post_vf = self._build_post_filters()
                if post_vf:
                    self.log_signal.emit(f"&#127912; 合成期滤镜: {post_vf}")
                    cmd_merge += ["-vf", post_vf]

                cmd_merge += [
                    "-c:v", "libx264", "-crf", "20", "-preset", "fast",
                    "-pix_fmt", "yuv420p", "-movflags", "+faststart"
                ]
                if has_audio:
                    cmd_merge += ["-c:a", "aac", "-b:a", "192k"]
                cmd_merge += [out_mp4]

                ret, merge_output = self._run_cmd(cmd_merge)
                if ret != 0:
                    raise RuntimeError(
                        f"视频合并失败:\n{merge_output[-500:]}"
                    )

                if not os.path.exists(out_mp4):
                    raise RuntimeError("输出文件不存在")

                sz = os.path.getsize(out_mp4) / (1024 * 1024)
                if sz < 0.01:
                    raise RuntimeError(f"输出文件异常小({sz:.2f}MB)")

                self.log_signal.emit(
                    f"&#127916; 完成: {os.path.basename(out_mp4)} ({sz:.1f}MB)"
                )
                self.progress_signal.emit(
                    int((vid_idx + 1) / max(total_videos, 1) * 100),
                    f"已完成: {base}"
                )

            except InterruptedError:
                self._emit_finished(False, "用户停止")
                return
            except Exception as e:
                self.log_signal.emit(f"&#10060; {base} 失败: {e}")
                self._emit_finished(False, f"{base} 失败")
                return
            finally:
                self._kill_all(wait=True)
                if os.path.exists(work_dir):
                    shutil.rmtree(work_dir, ignore_errors=True)

        if self._stop.is_set():
            self._emit_finished(False, "用户停止")
        else:
            self.progress_signal.emit(100, "全部完成")
            self._emit_finished(True, f"&#9989; {total_videos}个视频处理完成!")


# ==================== GUI ====================
class VideoRepairTool(QMainWindow):
    def __init__(self):
        super().__init__()
        self.worker = None
        self.util_worker = None
        self.output_dir = DEFAULT_OUTPUT_DIR
        self.init_ui()
        self.check_tools_startup()

    def init_ui(self):
        self.setWindowTitle("视频增强工具")
        self.resize(860, 700)
        central = QWidget()
        self.setCentralWidget(central)
        root = QVBoxLayout(central)
        root.setContentsMargins(14, 14, 14, 14)
        root.setSpacing(10)

        title = QLabel("&#127916; 视频增强工具")
        title.setAlignment(Qt.AlignCenter)
        title.setStyleSheet("font-size:26px; font-weight:bold; color:#e65100;")
        root.addWidget(title)

        content = QGridLayout()
        content.setColumnStretch(0, 5)
        content.setColumnStretch(1, 4)
        root.addLayout(content, 1)
        left, right = QVBoxLayout(), QVBoxLayout()

        # 文件列表
        file_group = QGroupBox("待处理视频列表")
        fl = QVBoxLayout(file_group)
        btn_row = QHBoxLayout()
        self.btn_choose_file = QPushButton("&#128269; 选择文件")
        self.btn_clear_list = QPushButton("&#128465; 清空列表")
        self.btn_choose_file.setStyleSheet("background:#43a047;color:white;border:none;padding:8px;border-radius:4px;")
        self.btn_clear_list.setStyleSheet("background:#fb8c00;color:white;border:none;padding:8px;border-radius:4px;")
        self.btn_choose_file.clicked.connect(self.add_files)
        self.btn_clear_list.clicked.connect(self.clear_list)
        btn_row.addWidget(self.btn_choose_file)
        btn_row.addWidget(self.btn_clear_list)
        fl.addLayout(btn_row)
        fl.addWidget(QLabel("待处理文件:"))
        self.list_files = QListWidget()
        fl.addWidget(self.list_files, 1)
        out_row = QHBoxLayout()
        out_row.addWidget(QLabel("输出文件夹:"))
        self.txt_out = QLineEdit(self.output_dir)
        self.btn_browse = QPushButton("浏览")
        self.btn_browse.setStyleSheet(
            "background:#4a90d9;color:white;border:none;padding:10px;font-weight:bold;border-radius:4px;")
        self.btn_browse.clicked.connect(self.choose_output_dir)
        out_row.addWidget(self.txt_out, 1)
        out_row.addWidget(self.btn_browse)
        fl.addLayout(out_row)
        left.addWidget(file_group, 3)

        # 功能选项
        func_group = QGroupBox("功能选项")
        fg = QGridLayout(func_group)
        self.chk_denoise = QCheckBox("去噪")
        self.chk_artifact = QCheckBox("去伪影")
        self.chk_color = QCheckBox("色彩增强")
        self.chk_sharpen = QCheckBox("锐化")
        self.chk_volume = QCheckBox("音频")
        self.chk_ai = QCheckBox("AI增强")
        self.chk_superres = QCheckBox("超分辨率")
        fg.addWidget(self.chk_denoise, 0, 0)
        fg.addWidget(self.chk_artifact, 1, 0)
        fg.addWidget(self.chk_color, 2, 0)
        fg.addWidget(self.chk_sharpen, 3, 0)
        fg.addWidget(self.chk_volume, 4, 0)
        fg.addWidget(self.chk_ai, 0, 1)
        fg.addWidget(self.chk_superres, 1, 1)

        scale_row = QHBoxLayout()
        scale_row.addWidget(QLabel("超分倍数:"))
        self.cmb_scale = QComboBox()
        self.cmb_scale.addItems(["2", "3", "4"])
        self.cmb_scale.setCurrentIndex(0)
        self.cmb_scale.setFixedWidth(80)
        self.lbl_scale_hint = QLabel("(AI增强后可用)")
        self.lbl_scale_hint.setStyleSheet("color:#888; font-size:11px;")
        scale_row.addWidget(self.cmb_scale)
        scale_row.addWidget(self.lbl_scale_hint, 1)
        fg.addLayout(scale_row, 2, 1)

        model_row = QHBoxLayout()
        model_row.addWidget(QLabel("AI模型:"))
        self.cmb_model = QComboBox()
        for mid, mlabel in MODELS:
            self.cmb_model.addItem(mlabel, mid)
        self.cmb_model.setCurrentIndex(0)
        model_row.addWidget(self.cmb_model, 1)
        fg.addLayout(model_row, 3, 1)

        for chk in [self.chk_ai, self.chk_superres, self.chk_denoise,
                    self.chk_artifact, self.chk_color, self.chk_sharpen, self.chk_volume]:
            chk.toggled.connect(self._update_controls)
        left.addWidget(func_group, 2)

        # 参数调节
        grp_params = QGroupBox("参数调节")
        params_layout = QFormLayout()
        params_layout.setSpacing(6)

        dn_row = QHBoxLayout()
        self.sld_dn_spatial = QSlider(Qt.Horizontal);
        self.sld_dn_spatial.setRange(0, 10)
        self.sld_dn_spatial.setValue(DEFAULT_POST_PARAMS["dn_spatial"])
        self.lbl_dn_spatial = QLabel(str(DEFAULT_POST_PARAMS["dn_spatial"]))
        self.sld_dn_spatial.valueChanged.connect(lambda v: self.lbl_dn_spatial.setText(str(v)))
        self.sld_dn_temporal = QSlider(Qt.Horizontal);
        self.sld_dn_temporal.setRange(0, 8)
        self.sld_dn_temporal.setValue(DEFAULT_POST_PARAMS["dn_temporal"])
        self.lbl_dn_temporal = QLabel(str(DEFAULT_POST_PARAMS["dn_temporal"]))
        self.sld_dn_temporal.valueChanged.connect(lambda v: self.lbl_dn_temporal.setText(str(v)))
        dn_row.addWidget(QLabel("空间:"));
        dn_row.addWidget(self.sld_dn_spatial, 1);
        dn_row.addWidget(self.lbl_dn_spatial)
        dn_row.addSpacing(16)
        dn_row.addWidget(QLabel("时间:"));
        dn_row.addWidget(self.sld_dn_temporal, 1);
        dn_row.addWidget(self.lbl_dn_temporal)
        params_layout.addRow("去噪强度:", dn_row)

        db_row = QHBoxLayout()
        self.cmb_deblock_mode = QComboBox()
        self.cmb_deblock_mode.addItems(["weak", "strong"])
        self.cmb_deblock_mode.setCurrentText(DEFAULT_POST_PARAMS["deblock_mode"])
        self.sld_deblock_block = QSlider(Qt.Horizontal);
        self.sld_deblock_block.setRange(4, 16)
        self.sld_deblock_block.setValue(DEFAULT_POST_PARAMS["deblock_block"])
        self.lbl_deblock_block = QLabel(str(DEFAULT_POST_PARAMS["deblock_block"]))
        self.sld_deblock_block.valueChanged.connect(lambda v: self.lbl_deblock_block.setText(str(v)))
        db_row.addWidget(QLabel("模式:"));
        db_row.addWidget(self.cmb_deblock_mode)
        db_row.addSpacing(16)
        db_row.addWidget(QLabel("块大小:"));
        db_row.addWidget(self.sld_deblock_block, 1);
        db_row.addWidget(self.lbl_deblock_block)
        params_layout.addRow("去伪影:", db_row)

        col_row = QHBoxLayout()
        self.sld_contrast = QSlider(Qt.Horizontal);
        self.sld_contrast.setRange(50, 200)
        self.sld_contrast.setValue(int(DEFAULT_POST_PARAMS["contrast"] * 100))
        self.lbl_contrast = QLabel(f"{DEFAULT_POST_PARAMS['contrast']:.2f}")
        self.sld_contrast.valueChanged.connect(lambda v: self.lbl_contrast.setText(f"{v / 100:.2f}"))
        self.sld_saturation = QSlider(Qt.Horizontal);
        self.sld_saturation.setRange(50, 250)
        self.sld_saturation.setValue(int(DEFAULT_POST_PARAMS["saturation"] * 100))
        self.lbl_saturation = QLabel(f"{DEFAULT_POST_PARAMS['saturation']:.2f}")
        self.sld_saturation.valueChanged.connect(lambda v: self.lbl_saturation.setText(f"{v / 100:.2f}"))
        self.sld_brightness = QSlider(Qt.Horizontal);
        self.sld_brightness.setRange(-50, 50)
        self.sld_brightness.setValue(int(DEFAULT_POST_PARAMS["brightness"] * 100))
        self.lbl_brightness = QLabel(f"{DEFAULT_POST_PARAMS['brightness']:.2f}")
        self.sld_brightness.valueChanged.connect(lambda v: self.lbl_brightness.setText(f"{v / 100:.2f}"))
        col_row.addWidget(QLabel("对比:"));
        col_row.addWidget(self.sld_contrast, 1);
        col_row.addWidget(self.lbl_contrast)
        col_row.addSpacing(8)
        col_row.addWidget(QLabel("饱和:"));
        col_row.addWidget(self.sld_saturation, 1);
        col_row.addWidget(self.lbl_saturation)
        col_row.addSpacing(8)
        col_row.addWidget(QLabel("亮度:"));
        col_row.addWidget(self.sld_brightness, 1);
        col_row.addWidget(self.lbl_brightness)
        params_layout.addRow("色彩:", col_row)

        sh_row = QHBoxLayout()
        self.sld_sharp_luma = QSlider(Qt.Horizontal);
        self.sld_sharp_luma.setRange(0, 30)
        self.sld_sharp_luma.setValue(int(DEFAULT_POST_PARAMS["sharp_luma"] * 10))
        self.lbl_sharp_luma = QLabel(f"{DEFAULT_POST_PARAMS['sharp_luma']:.1f}")
        self.sld_sharp_luma.valueChanged.connect(lambda v: self.lbl_sharp_luma.setText(f"{v / 10:.1f}"))
        self.sld_sharp_chroma = QSlider(Qt.Horizontal);
        self.sld_sharp_chroma.setRange(0, 30)
        self.sld_sharp_chroma.setValue(int(DEFAULT_POST_PARAMS["sharp_chroma"] * 10))
        self.lbl_sharp_chroma = QLabel(f"{DEFAULT_POST_PARAMS['sharp_chroma']:.1f}")
        self.sld_sharp_chroma.valueChanged.connect(lambda v: self.lbl_sharp_chroma.setText(f"{v / 10:.1f}"))
        sh_row.addWidget(QLabel("亮度:"))
        sh_row.addWidget(self.sld_sharp_luma, 1)
        sh_row.addWidget(self.lbl_sharp_luma)
        sh_row.addSpacing(16)
        sh_row.addWidget(QLabel("色度:"));
        sh_row.addWidget(self.sld_sharp_chroma, 1)
        sh_row.addWidget(self.lbl_sharp_chroma)
        params_layout.addRow("锐化:", sh_row)

        vol_row = QHBoxLayout()
        self.sld_volume = QSlider(Qt.Horizontal);
        self.sld_volume.setRange(0, 30)
        self.sld_volume.setValue(int(DEFAULT_POST_PARAMS["volume"] * 10))
        self.lbl_volume = QLabel(f"{DEFAULT_POST_PARAMS['volume']:.1f}")
        self.sld_volume.valueChanged.connect(lambda v: self.lbl_volume.setText(f"{v / 10:.1f}"))
        vol_row.addWidget(QLabel("音量:"))
        vol_row.addWidget(self.sld_volume, 1)
        vol_row.addWidget(self.lbl_volume)
        vol_row.addStretch()
        params_layout.addRow("音频:", vol_row)

        self.btn_reset_params = QPushButton("&#8617; 恢复缺省值")
        self.btn_reset_params.setStyleSheet(
            "background:#78909c;color:white;border:none;padding:6px;border-radius:4px;font-size:12px;")
        self.btn_reset_params.clicked.connect(self._reset_post_params)
        params_layout.addRow("", self.btn_reset_params)
        grp_params.setLayout(params_layout)
        left.addWidget(grp_params, 2)

        ctrl_row = QHBoxLayout()
        self.btn_start = QPushButton("&#128640; 开始处理")
        self.btn_stop = QPushButton("&#9209; 停止")
        self.btn_start.setStyleSheet(
            "QPushButton{background:#43a047;color:white;border:none;padding:10px;font-weight:bold;border-radius:4px;}QPushButton:disabled{background:#bdbdbd;color:#757575;}")
        self.btn_stop.setStyleSheet(
            "QPushButton{background:#e53935;color:white;border:none;padding:10px;font-weight:bold;border-radius:4px;}QPushButton:disabled{background:#bdbdbd;color:#757575;}")
        self.btn_start.clicked.connect(self.start_process)
        self.btn_stop.clicked.connect(self.stop_process)
        self.btn_stop.setEnabled(False)
        ctrl_row.addWidget(self.btn_start, 1)
        ctrl_row.addWidget(self.btn_stop, 1)
        left.addLayout(ctrl_row)

        hint = QLabel(f"&#128161; CPU {N_WORKERS} 进程并行 | GPU自动选择 | 关闭窗口自动结束子进程")
        hint.setStyleSheet("color:#e65100; font-size:12px; font-weight:bold;")
        hint.setAlignment(Qt.AlignCenter)
        left.addWidget(hint)
        content.addLayout(left, 0, 0)

        # 右侧
        progress_group = QGroupBox("处理进度")
        pl = QVBoxLayout(progress_group)
        self.lbl_status = QLabel("状态: 就绪")
        self.lbl_status.setStyleSheet("font-weight:bold; color:#2979ff; font-size:10px;")
        pl.addWidget(self.lbl_status)
        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 100)
        pl.addWidget(self.progress_bar)
        right.addWidget(progress_group, 1)

        util_group = QGroupBox("辅助功能")
        ug = QGridLayout(util_group)
        self.btn_open_out = QPushButton("打开输出文件夹")
        self.btn_check_ffmpeg = QPushButton("检查 FFmpeg")
        self.btn_open_out.clicked.connect(self.open_output_dir)
        self.btn_check_ffmpeg.clicked.connect(lambda: self.run_utility(self.check_ffmpeg_status, "检查FFmpeg"))
        ug.addWidget(self.btn_open_out, 0, 0)
        ug.addWidget(self.btn_check_ffmpeg, 0, 1)
        right.addWidget(util_group, 1)

        log_group = QGroupBox("处理日志")
        ll = QVBoxLayout(log_group)
        self.log_text = QPlainTextEdit()
        self.log_text.setReadOnly(True)
        ll.addWidget(self.log_text, 1)
        btn_clear_log = QPushButton("清空日志")
        btn_clear_log.clicked.connect(lambda: self.log_text.clear())
        ll.addWidget(btn_clear_log)
        right.addWidget(log_group, 5)
        content.addLayout(right, 0, 1)

        self._freezable_widgets = [
            self.chk_denoise, self.chk_artifact, self.chk_color, self.chk_sharpen, self.chk_volume,
            self.chk_ai, self.chk_superres, self.cmb_scale, self.cmb_model,
            self.sld_dn_spatial, self.lbl_dn_spatial, self.sld_dn_temporal, self.lbl_dn_temporal,
            self.cmb_deblock_mode, self.sld_deblock_block, self.lbl_deblock_block,
            self.sld_contrast, self.lbl_contrast, self.sld_saturation, self.lbl_saturation,
            self.sld_brightness, self.lbl_brightness,
            self.sld_sharp_luma, self.lbl_sharp_luma, self.sld_sharp_chroma, self.lbl_sharp_chroma,
            self.list_files, self.txt_out, self.btn_choose_file, self.btn_clear_list, self.btn_browse,
            self.sld_volume, self.lbl_volume, self.btn_reset_params,
        ]
        self._update_controls()
        self.setStyleSheet("""
            QGroupBox{font-weight:bold; border:1px solid #e0e0e0; border-radius:6px; margin-top:12px; padding-top:8px;}
            QGroupBox::title{subcontrol-origin:margin; left:10px; padding:0 4px; color:#2979ff;}
            QListWidget, QPlainTextEdit, QLineEdit, QComboBox{border:1px solid #ccc; border-radius:4px; padding:4px;}
        """)

    def _update_controls(self):
        def _se(name, enabled):
            w = getattr(self, name, None)
            if w is not None: w.setEnabled(enabled)

        ai_on = self.chk_ai.isChecked()
        sr_on = self.chk_superres.isChecked()
        if not ai_on and sr_on:
            self.chk_superres.blockSignals(True)
            self.chk_superres.setChecked(False)
            self.chk_superres.blockSignals(False)
        _se("chk_superres", ai_on)
        _se("cmb_scale", ai_on and self.chk_superres.isChecked())
        _se("cmb_model", ai_on)
        _se("sld_dn_spatial", self.chk_denoise.isChecked())
        _se("sld_dn_temporal", self.chk_denoise.isChecked())
        _se("lbl_dn_spatial", self.chk_denoise.isChecked())
        _se("lbl_dn_temporal", self.chk_denoise.isChecked())
        _se("cmb_deblock_mode", self.chk_artifact.isChecked())
        _se("sld_deblock_block", self.chk_artifact.isChecked())
        _se("lbl_deblock_block", self.chk_artifact.isChecked())
        _se("sld_contrast", self.chk_color.isChecked())
        _se("sld_saturation", self.chk_color.isChecked())
        _se("sld_volume", self.chk_volume.isChecked())
        _se("sld_brightness", self.chk_color.isChecked())
        _se("lbl_contrast", self.chk_color.isChecked())
        _se("lbl_saturation", self.chk_color.isChecked())
        _se("lbl_brightness", self.chk_color.isChecked())
        _se("sld_sharp_luma", self.chk_sharpen.isChecked())
        _se("sld_sharp_chroma", self.chk_sharpen.isChecked())
        _se("lbl_sharp_luma", self.chk_sharpen.isChecked())
        _se("lbl_sharp_chroma", self.chk_sharpen.isChecked())
        _se("lbl_volume", self.chk_volume.isChecked())

    def _set_controls_enabled(self, enabled):
        for w in self._freezable_widgets: w.setEnabled(enabled)
        if enabled: self._update_controls()

    def _get_post_params(self):
        return {
            "dn_spatial": self.sld_dn_spatial.value(), "dn_temporal": self.sld_dn_temporal.value(),
            "deblock_mode": self.cmb_deblock_mode.currentText(), "deblock_block": self.sld_deblock_block.value(),
            "contrast": self.sld_contrast.value() / 100.0, "saturation": self.sld_saturation.value() / 100.0,
            "brightness": self.sld_brightness.value() / 100.0,
            "sharp_luma": self.sld_sharp_luma.value() / 10.0, "sharp_chroma": self.sld_sharp_chroma.value() / 10.0,
            "volume": self.sld_volume.value() / 10.0,
        }

    def _reset_post_params(self):
        d = DEFAULT_POST_PARAMS
        self.sld_dn_spatial.setValue(d["dn_spatial"]);
        self.sld_dn_temporal.setValue(d["dn_temporal"])
        self.cmb_deblock_mode.setCurrentText(d["deblock_mode"]);
        self.sld_deblock_block.setValue(d["deblock_block"])
        self.sld_contrast.setValue(int(d["contrast"] * 100));
        self.sld_saturation.setValue(int(d["saturation"] * 100))
        self.sld_brightness.setValue(int(d["brightness"] * 100))
        self.sld_sharp_luma.setValue(int(d["sharp_luma"] * 10));
        self.sld_sharp_chroma.setValue(int(d["sharp_chroma"] * 10))
        self.sld_volume.setValue(int(d["volume"] * 10))
        self.append_log("&#8617; 参数已恢复缺省值")

    def check_tools_startup(self):
        for p, name in [(REALESRGAN_PATH, "Real&#8209;ESRGAN"), (FFMPEG_PATH, "FFmpeg"), (FFPROBE_PATH, "FFprobe")]:
            if not p or not os.path.exists(p): self.append_log(f"&#9888;&#65039; 未找到 {name}: {p}")

    def add_files(self):
        files, _ = QFileDialog.getOpenFileNames(self, "选择视频", "",
                                                "视频文件 (*.mp4 *.avi *.mkv *.mov *.flv *.wmv *.ts)")
        for f in files:
            if f not in [self.list_files.item(i).text() for i in range(self.list_files.count())]:
                self.list_files.addItem(f)

    def clear_list(self):
        self.list_files.clear()

    def choose_output_dir(self):
        d = QFileDialog.getExistingDirectory(self, "选择输出目录", self.output_dir)
        if d: self.output_dir = d; self.txt_out.setText(d)

    def open_output_dir(self):
        d = self.txt_out.text().strip() or DEFAULT_OUTPUT_DIR
        os.makedirs(d, exist_ok=True)
        try:
            if os.name == "nt":
                os.startfile(d)
            elif sys.platform == "darwin":
                subprocess.Popen(["open", d])
            else:
                subprocess.Popen(["xdg-open", d])
        except Exception as e:
            QMessageBox.warning(self, "提示", f"无法打开: {e}")

    def start_process(self):
        if self.worker and self.worker.isRunning():
            QMessageBox.warning(self, "警告", "上一个任务尚未结束");
            return
        files = [self.list_files.item(i).text() for i in range(self.list_files.count())]
        if not files: QMessageBox.warning(self, "提示", "请先添加至少 1 个视频文件!"); return
        for p, name in [(FFMPEG_PATH, "FFmpeg"), (FFPROBE_PATH, "FFprobe")]:
            if not p or not os.path.exists(p): QMessageBox.warning(self, "警告", f"未找到 {name}: {p}"); return
        use_ai = self.chk_ai.isChecked()
        if use_ai and (not REALESRGAN_PATH or not os.path.exists(REALESRGAN_PATH)):
            QMessageBox.warning(self, "警告", f"未找到 Real&#8209;ESRGAN: {REALESRGAN_PATH}");
            return
        if not any([use_ai, self.chk_denoise.isChecked(), self.chk_artifact.isChecked(),
                    self.chk_color.isChecked(), self.chk_sharpen.isChecked()]):
            QMessageBox.warning(self, "提示", "请至少选择一个处理功能!");
            return

        self.output_dir = self.txt_out.text().strip() or DEFAULT_OUTPUT_DIR
        os.makedirs(self.output_dir, exist_ok=True)
        use_sr = self.chk_superres.isChecked() and use_ai
        scale = int(self.cmb_scale.currentText()) if use_sr else 1
        model = self.cmb_model.currentData() if use_ai else ""
        options = {"ai": use_ai, "denoise": self.chk_denoise.isChecked(),
                   "artifact": self.chk_artifact.isChecked(), "color": self.chk_color.isChecked(),
                   "sharpen": self.chk_sharpen.isChecked()}

        self.worker = WorkerThread(files, self.output_dir, scale=scale, model=model,
                                   options=options, post_params=self._get_post_params())
        self.worker.progress_signal.connect(self.update_progress, Qt.QueuedConnection)
        self.worker.log_signal.connect(self.append_log, Qt.QueuedConnection)
        self.worker.finished_signal.connect(self.process_finished, Qt.QueuedConnection)

        self.progress_bar.setValue(0)
        self.btn_start.setEnabled(False);
        self.btn_stop.setEnabled(True)
        self._set_controls_enabled(False)

        feats = []
        if self.chk_denoise.isChecked(): feats.append("去噪")
        if self.chk_artifact.isChecked(): feats.append("去伪影")
        if self.chk_color.isChecked(): feats.append("色彩")
        if self.chk_sharpen.isChecked(): feats.append("锐化")
        if use_ai: feats.append(f"AI({model})")
        if use_sr: feats.append(f"{scale}x超分")
        self.append_log(f"&#128640; 启动 | CPU×{N_WORKERS} | {' + '.join(feats)}")
        self.worker.start()

    def stop_process(self):
        if self.worker:
            self.btn_stop.setEnabled(False);
            self.btn_start.setEnabled(True)
            self.lbl_status.setText("状态: 正在停止…");
            self.worker.stop()

    def update_progress(self, value, text):
        if value >= 0: self.progress_bar.setValue(value)
        if text: self.lbl_status.setText(f"状态: {text}")

    def append_log(self, text):
        self.log_text.appendPlainText(f"[{time.strftime('%H:%M:%S')}] {text}")
        self.log_text.ensureCursorVisible()

    def process_finished(self, success, msg):
        is_stop = "用户停止" in msg
        self._set_controls_enabled(True)
        if is_stop:
            self.btn_stop.setEnabled(False);
            self.lbl_status.setText("状态: 已停止 &#128721;")
            self.append_log("\n--- 处理已停止 ---")
        elif success:
            self.btn_start.setEnabled(True);
            self.btn_stop.setEnabled(False)
            self.lbl_status.setText("状态: 全部完成 &#9989;");
            self.progress_bar.setValue(100)
            self.append_log(f"\n&#127881; {msg}")
        else:
            self.btn_start.setEnabled(True);
            self.btn_stop.setEnabled(False)
            self.lbl_status.setText(f"状态: {msg}")
            self.append_log(f"\n--- 处理结束: {msg} ---")

        def _cleanup():
            if self.worker is not None:
                try:
                    self.worker.blockSignals(True); self.worker.quit(); self.worker.wait(3000)
                except RuntimeError:
                    pass
                self.worker.deleteLater();
                self.worker = None
            if success and not is_stop: QMessageBox.information(self, "处理完成", msg)

        QTimer.singleShot(0, _cleanup)

    def run_utility(self, func, title):
        if self.util_worker and self.util_worker.isRunning(): return
        self.append_log(f"&#128270; {title}…")
        self.util_worker = UtilityWorker(func, self)
        self.util_worker.log_signal.connect(self.append_log, Qt.QueuedConnection)
        self.util_worker.finished_signal.connect(lambda ok, msg: setattr(self, "util_worker", None),
                                                 Qt.QueuedConnection)
        self.util_worker.start()

    def check_ffmpeg_status(self):
        if not FFMPEG_PATH or not os.path.exists(FFMPEG_PATH): return f"&#10060; 未找到: {FFMPEG_PATH}"
        try:
            r = subprocess.run([FFMPEG_PATH, "-version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                               text=True, encoding="utf-8", errors="ignore", timeout=10)
            first = (r.stdout or "").strip().splitlines()[0] if r.stdout else "?"
            return f"&#9989; FFmpeg: {first}"
        except subprocess.TimeoutExpired:
            return "&#10060; FFmpeg 版本查询超时"
        except Exception as e:
            return f"&#10060; FFmpeg 检查失败: {e}"

    def closeEvent(self, event):
        if self.worker and self.worker.isRunning():
            self.worker.stop();
            self.worker.wait(5000)
            if self.worker.isRunning(): self.worker.terminate(); self.worker.wait(2000)
        if self.worker is not None:
            self.worker.blockSignals(True);
            self.worker.deleteLater();
            self.worker = None
        if self.util_worker and self.util_worker.isRunning():
            self.util_worker.wait(1000);
            self.util_worker.deleteLater();
            self.util_worker = None
        event.accept()


def main():
    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    app = QApplication(sys.argv)
    win = VideoRepairTool()
    win.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()

免费评分

参与人数 7吾爱币 +13 热心值 +5 收起 理由
苏紫方璇 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
laozhang4201 + 1 + 1 热心回复!
wapj20260721 + 1 热心回复!
大森林 + 1 + 1 热心回复!
youngdh + 1 + 1 鼓励转贴优秀软件安全工具和文档!
passaway + 1 我很赞同!
chinawolf2000 + 1 + 1 热心回复!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

大森林 发表于 2026-8-27 09:20
本帖最后由 大森林 于 2026-8-27 09:37 编辑

#128269;选择文件   图标变成乱码,我把所有 HTML 实体编码已全部替换为 Unicode emoji 字符,正常了
 楼主| psqladm 发表于 2026-8-26 20:13
发错位置了,劳驾版主给转到编程语言区,给您添麻烦了,谢谢!
walnut1314 发表于 2026-8-26 20:46
DSSHM 发表于 2026-8-26 21:17
在哪里下载?
cux666 发表于 2026-8-26 21:38
等等下载连接,赞
chinchilla 发表于 2026-8-26 23:16
这个不错,很有创意
cxwapj66 发表于 2026-8-26 23:21
楼主,给个下载链接谢谢
goodpanda 发表于 2026-8-27 00:13
所以处理视频用CPU而不是GPU?
liouxunhuan 发表于 2026-8-27 02:01
好东西
有没有大神整个大概教程的
感谢分享 强烈支持
wuran 发表于 2026-8-27 06:42
能去马赛克就好了,收藏了,期待更强版本
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-8 05:00

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表