用PySide6 构建一个响应式视频剪辑工具:多线程与信号机制实战

从长视频中批量剪辑出精彩片段。传统的做法是打开笨重的剪辑软件手动操作,效率低下。作为一个开发者,我们自然会想:能不能写个脚本来自动化这个过程?

当然可以。但如果想让这个工具更易用,一个图形用户界面是必不可少的。而这恰恰引入了 GUI 编程中最经典的挑战:如何在执行耗时任务(如视频处理)的同时,保持界面的流畅响应?

这篇文章将分享我写的一个app.py单文件小工具,它能根据字幕文件批量剪辑视频。并聊聊如何使用 PySide6框架,通过 QThreadPool 和信号/槽机制,构建一个高性能、不卡顿的桌面应用。

UI线程的"生命不可承受之重"

任何 GUI 框架,无论是前端的浏览器、Android 的 View 系统还是 Qt,都有一个主线程,也叫 UI 线程。它负责处理用户输入、绘制界面、响应事件。如果在这个线程里执行一个耗时任务,比如调用 ffmpeg 命令来剪辑一个 10 秒的视频,那么在这 10 秒内,整个应用界面将完全冻结------无法点击按钮,无法拖动窗口,在用户看来就是"程序卡死了"。

这就是我们首先要解决的问题。解决方案很明确:将所有耗时操作都放到后台工作线程中去执行。

架构设计:QThreadPoolQRunnable

在 Qt 中,处理并发任务的首选方式之一就是 QThreadPoolQRunnable

  • QThreadPool: 一个托管的线程池,负责管理和复用工作线程,避免了手动创建和销毁线程的开销。
  • QRunnable: 一个轻量级的工作任务抽象类。我们只需要继承它并实现 run() 方法,就可以将具体的业务逻辑(如调用 ffmpeg)封装起来。

在我的代码中,定义了两个这样的任务类:

  1. LoadSubtitlesTask: 负责读取和解析字幕文件。虽然这通常很快,但对于非常大的字幕文件,也可能造成瞬间卡顿,因此也将其放入后台。
  2. ClipTask: 核心的剪辑任务,封装了对 subprocess.run() 的调用来执行 ffmpeg 命令。
python 复制代码
class ClipTask(QRunnable):
    def __init__(self, video_path, sub, line_num, ...):
        super().__init__()
        # ... 初始化

    def run(self):
        # ... 构建 ffmpeg 命令
        cmd = ["ffmpeg", "-ss", start_time, "-i", video_path, ...]
        try:
            # 这是耗时操作
            subprocess.run(cmd, check=True, capture_output=True)
            # 任务成功了
        except Exception as e:
            # 任务失败了
            pass

现在问题来了:任务在后台线程执行,但执行的结果(成功、失败、进度)需要更新到主线程的 UI 控件上。直接在工作线程中操作 UI 控件是绝对禁止的,这会导致线程不安全,轻则界面错乱,重则程序崩溃。

这就是 Qt 的精髓------信号与槽(Signal/Slot)机制------登场的时候了。

用信号与槽实现线程安全通信

信号与槽是 Qt 框架的核心特性,它是一种听起来有些复杂但实际还算简单的观察者模式实现,完美地解决了跨线程通信的难题。

  • Signal(信号): 当某个特定事件发生时,一个对象可以"发射(emit)"一个信号。信号可以携带参数。
  • Slot(槽): 一个可以接收并处理信号的函数或方法。

当一个对象的信号连接(connect)到另一个对象的槽时,一旦信号被发射,与之关联的槽函数就会被自动调用。最关键的是,如果信号是从工作线程发射,而接收槽位于主线程的对象上,Qt 会自动、安全地将这个调用安排到主线程的事件循环中执行。

在我的代码中,创建了一个专门的 Signals 类来统一定义所有需要用到的信号:

python 复制代码
# 必须继承自 QObject 才能定义信号
class Signals(QObject):

    # 进度更新信号,携带一个字符串参数
    progress = Signal(str)
    
    # 所有任务完成的信号,不带参数
    finished = Signal()
    
    # 字幕加载完成的信号,携带解析后的数据和文件名
    subtitles_loaded = Signal(list, str)
    
    # 加载出错的信号
    load_error = Signal(str)

工作流程:

  1. 初始化与连接 :在主窗口 MainWindow__init__ 方法中,实例化 Signals 类,并将信号连接到对应的槽函数。

    python 复制代码
    class MainWindow(QWidget):
        def __init__(self):
            # ...
            self.signals = Signals()
            # 将 progress 信号连接到 update_progress 槽函数
            self.signals.progress.connect(self.update_progress)
            self.signals.finished.connect(self.clipping_finished)
            # ...
  2. 任务分发与信号发射 :当用户点击"开始剪辑"按钮时,start_clipping 方法会为每个选中的字幕行创建一个 ClipTask 实例,并将我们的 signals 对象传递给它。

    python 复制代码
    # 在 start_clipping 中
    for line_num in self.selected_lines:
        sub = self.subtitles[line_num - 1]
        # 将 signals 对象传入任务
        task = ClipTask(..., self.signals)
        # 提交到线程池执行
        self.thread_pool.start(task)
  3. 后台发射信号ClipTask 在其 run 方法中执行完 ffmpeg 命令后,根据结果发射不同的信号。

    python 复制代码
    # 在 ClipTask.run 中
    class ClipTask(QRunnable):
        def run(self):
            try:
                subprocess.run(...)
                # 任务成功,从工作线程发射 progress 信号
                self.signals.progress.emit(f"Completed clip {self.line_num}")
            except subprocess.CalledProcessError as e:
                # 任务失败,同样发射 progress 信号,但内容是错误信息
                self.signals.progress.emit(f"Failed clip {self.line_num}: {e}")
  4. 主线程安全接收并更新 UI :由于我们在第一步中建立了连接,主线程的 update_progress 方法会被自动调用。我们可以在这个方法里安全地更新 QLabel 等 UI 控件。

    python 复制代码
    # 在 MainWindow 中定义的槽函数
    def update_progress(self, message: str):
        if "Completed" in message:
            self.completed_clips += 1
        elif "Failed" in message:
            self.failed_clips.append(message)
    
        self.progress_label.setText(
            f"共: {self.total_clips}, 完成: {self.completed_clips}, 失败: {len(self.failed_clips)}"
        )
    
        # 检查是否所有任务都结束了
        if self.completed_clips + len(self.failed_clips) >= self.total_clips:
            self.signals.finished.emit()

通过这个机制,我们成功地将耗时的业务逻辑与 UI 更新解耦,实现了应用的流畅响应。即使用户一次性剪辑上百个片段,界面也依然能自如操作。

uv 实现零依赖运行

对于 Python 开发者来说,环境和依赖管理常常是个头疼的问题。为了让这个工具对技术小白也足够友好,我采用了最近大火的 uv 工具。

通过在脚本文件头部添加特定的注释,uv 能够读取这些元数据,自动创建一个虚拟环境并安装所需的依赖,然后执行脚本。

python 复制代码
# /// script
# requires-python = ">=3.10,<=3.12"
# dependencies = [
#   "pyside6",
#   "pysubs2"
# ]
# ///

这意味着用户只需要下载 uv.exeapp.py,无需手动 pip install 任何东西,只需在命令行运行 uv run app.py 即可启动程序。这极大地降低了使用门槛,也为代码分发提供了一种轻量级的解决方案。

完整代码

下面是完整的项目代码,涵盖了从 UI 布局、事件处理到并发编程和线程安全通信的各个方面。

python 复制代码
# /// script
# requires-python = ">=3.10,<=3.12"
# dependencies = [
#   "pyside6",
#   "pysubs2"
# ]
# 
# [[tool.uv.index]]
# url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
# 	
# ///

import sys
import os,shutil,platform
from pathlib import Path
import subprocess
from PySide6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
    QLabel, QFileDialog, QListWidget, QListWidgetItem, QCheckBox,
    QComboBox
)
from PySide6.QtCore import Qt, QThreadPool, QRunnable, Signal, QObject, QUrl, Slot,QSize
from PySide6.QtGui import QDesktopServices
from datetime import timedelta
import pysubs2

# 全局输出文件夹
ROOT_DIR=Path(os.getcwd()).as_posix()
output_folder = f"{ROOT_DIR}/output"


class Signals(QObject):
    progress = Signal(str)
    finished = Signal()
    subtitles_loaded = Signal(list, str)  # list of (i, text), subtitle_name
    load_error = Signal(str)

class ClipTask(QRunnable):
    def __init__(self, video_path, sub, line_num, subtitle_name, signals, mode):
        super().__init__()
        self.video_path = video_path
        self.sub = sub
        self.line_num = line_num
        self.subtitle_name = subtitle_name
        self.signals = signals
        self.mode = mode

    def run(self):
        try:
            start_time = self.sub.start / 1000.0 
            duration = (self.sub.end - self.sub.start) / 1000.0
            output_dir = f'{output_folder}/{self.subtitle_name}'
            os.makedirs(output_dir, exist_ok=True)

            if self.mode == 0:  # 默认
                output_path = os.path.join(output_dir, f"{self.line_num}.mp4")
                cmd = [
                    "ffmpeg","-y", "-ss", str(start_time), "-t", str(duration),
                    "-i", self.video_path, "-c:v", "copy", "-c:a", "copy",
                    output_path
                ]
                subprocess.run(cmd, check=True, capture_output=True)
            elif self.mode == 1:  # 仅视频
                output_path = os.path.join(output_dir, f"{self.line_num}.mp4")
                cmd = [
                    "ffmpeg","-y", "-ss", str(start_time), "-t", str(duration),
                    "-i", self.video_path, "-c:v", "copy", "-an",
                    output_path
                ]
                subprocess.run(cmd, check=True, capture_output=True)
            elif self.mode == 2:  # 仅音频
                output_path = os.path.join(output_dir, f"{self.line_num}.wav")
                cmd = [
                    "ffmpeg", "-y","-ss", str(start_time), "-t", str(duration),
                    "-i", self.video_path, "-vn", "-c:a", "pcm_s16le",
                    output_path
                ]
                subprocess.run(cmd, check=True, capture_output=True)
            elif self.mode == 3:  # 分离
                # 无声视频
                video_path_out = os.path.join(output_dir, f"{self.line_num}.mp4")
                cmd_video = [
                    "ffmpeg","-y", "-ss", str(start_time), "-t", str(duration),
                    "-i", self.video_path, "-c:v", "copy", "-an",
                    video_path_out
                ]
                subprocess.run(cmd_video, check=True, capture_output=True)

                # 音频
                audio_path_out = os.path.join(output_dir, f"{self.line_num}.wav")
                cmd_audio = [
                    "ffmpeg","-y", "-ss", str(start_time), "-t", str(duration),
                    "-i", self.video_path, "-vn", "-c:a", "pcm_s16le",
                    audio_path_out
                ]
                subprocess.run(cmd_audio, check=True, capture_output=True)
            # 注意:Completed 和 Failed 不可修改,UI线程据此判断成功与失败,丑陋但简单
            self.signals.progress.emit(f"Completed clip {self.line_num}")
        except subprocess.CalledProcessError as e:
            error_msg = e.stderr.decode() if e.stderr else str(e)
            self.signals.progress.emit(f"Failed clip {self.line_num}: {error_msg}")
        except Exception as e:
            self.signals.progress.emit(f"Failed clip {self.line_num}: {str(e)}")

class LoadSubtitlesTask(QRunnable):
    def __init__(self, subtitle_path, signals):
        super().__init__()
        self.subtitle_path = subtitle_path
        self.signals = signals

    
    def _format_time(self,ms):
        hours = ms // (1000 * 3600)
        minutes = (ms // (1000 * 60)) % 60
        seconds = (ms // 1000) % 60
        milliseconds = ms % 1000
        return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"

    def run(self):
        try:
            subtitles = pysubs2.load(self.subtitle_path)
            subtitle_name = os.path.splitext(os.path.basename(self.subtitle_path))[0]
            data = []
            for i, sub in enumerate(subtitles, 1):
                start = self._format_time(sub.start)
                end = self._format_time(sub.end)
                duration = (sub.end - sub.start) / 1000.0
                text = f"{start}->{end}({duration:.2f}s) {sub.text}"
                data.append((i, text))
            self.signals.subtitles_loaded.emit(data, subtitle_name)
        except Exception as e:
            self.signals.load_error.emit(str(e))

class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("按字幕剪辑视频")
        self.resize(1000, 600)
        self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint | Qt.WindowMinimizeButtonHint)

        self.video_path = None
        self.subtitle_path = None
        self.subtitles = None
        self.subtitle_name = None
        self.selected_lines = []
        self.thread_pool = QThreadPool()
        self.is_clipping = False
        self.signals = Signals()
        self.signals.progress.connect(self.update_progress)
        self.signals.finished.connect(self.clipping_finished)
        self.signals.subtitles_loaded.connect(self.on_subtitles_loaded)
        self.signals.load_error.connect(self.on_load_error)
        self.total_clips = 0
        self.completed_clips = 0
        self.failed_clips = []
        self.open_button = None
        self.active_tasks = 0

        self.setup_ui()

    def setup_ui(self):
        layout = QVBoxLayout()

        # 文件选择
        file_layout = QHBoxLayout()
        self.video_label = QLabel("未选视频")
        video_btn = QPushButton("选择视频")
        video_btn.clicked.connect(self.select_video)
        video_btn.setMinimumSize(QSize(200, 35))
        video_btn.setCursor(Qt.PointingHandCursor)
        file_layout.addWidget(video_btn)
        file_layout.addWidget(self.video_label)

        self.subtitle_label = QLabel("未选字幕")
        subtitle_btn = QPushButton("选择字幕")
        subtitle_btn.setMinimumSize(QSize(200, 35))
        subtitle_btn.clicked.connect(self.select_subtitle)
        subtitle_btn.setCursor(Qt.PointingHandCursor)
        file_layout.addWidget(subtitle_btn)
        file_layout.addWidget(self.subtitle_label)

        # 输出模式下拉列表
        self.output_mode = QComboBox()
        self.output_mode.addItems([
            "默认(mp4片段有声)",
            "仅保留视频(mp4片段无声)",
            "仅保留音频(wav音频片段)",
            "声画分离(mp4片段无声和wav音频片段)"
        ])
        file_layout.addWidget(self.output_mode)
        file_layout.addStretch() 
        layout.addLayout(file_layout)



        # 批量选择按钮
        batch_layout = QHBoxLayout()
        select_all_btn = QPushButton("全选")
        select_all_btn.clicked.connect(self.select_all)
        select_all_btn.setCursor(Qt.PointingHandCursor)
        batch_layout.addWidget(select_all_btn)

        deselect_all_btn = QPushButton("全不选")
        deselect_all_btn.clicked.connect(self.deselect_all)
        deselect_all_btn.setCursor(Qt.PointingHandCursor)
        batch_layout.addWidget(deselect_all_btn)

        invert_btn = QPushButton("反选")
        invert_btn.clicked.connect(self.invert_selection)
        invert_btn.setCursor(Qt.PointingHandCursor)
        batch_layout.addWidget(invert_btn)
        batch_layout.addStretch()
        layout.addLayout(batch_layout)

        # 字幕列表
        self.subtitle_list = QListWidget()
        layout.addWidget(self.subtitle_list)

        # 底部按钮
        btn_layout = QHBoxLayout()

        self.clip_btn = QPushButton("开始剪辑")
        self.clip_btn.setCursor(Qt.PointingHandCursor)
        self.clip_btn.setMinimumSize(QSize(200, 35))
        self.clip_btn.clicked.connect(self.start_clipping)
        btn_layout.addWidget(self.clip_btn)

        self.clear_btn = QPushButton("清除已选")
        self.clear_btn.setCursor(Qt.PointingHandCursor)
        self.clear_btn.setMaximumWidth(150)
        self.clear_btn.clicked.connect(self.clear_all)
        btn_layout.addWidget(self.clear_btn)
        
        self.open_button = QPushButton("打开输出目录")
        self.open_button.setMaximumWidth(200)
        self.open_button.setCursor(Qt.PointingHandCursor)
        self.open_button.clicked.connect(self.open_output_folder)
        self.open_button.hide()
        btn_layout.addWidget(self.open_button)
        

        layout.addLayout(btn_layout)


        self.progress_label = QLabel("")
        self.progress_label.setStyleSheet('color:#2196f3;font-size:14px')
        layout.addWidget(self.progress_label)

        self.setLayout(layout)

    def select_video(self):
        path, _ = QFileDialog.getOpenFileName(self, "选择视频", "", "Video Files (*.mp4 *.avi *.mkv)")
        if path:
            self.video_path = path
            self.video_label.setText(os.path.basename(path))

    def select_subtitle(self):
        global output_folder
        path, _ = QFileDialog.getOpenFileName(self, "选择对应字幕", "", "Subtitle Files (*.srt *.ass *.vtt)")
        if path:
            output_folder=Path(path).parent.as_posix()
            self.subtitle_path = path
            self.subtitle_label.setText(os.path.basename(path))
            self.subtitle_list.clear()
            self.progress_label.setText("正在渲染字幕...")
            task = LoadSubtitlesTask(self.subtitle_path, self.signals)
            self.thread_pool.start(task)

    @Slot(list, str)
    def on_subtitles_loaded(self, data, subtitle_name):
        self.subtitles = pysubs2.load(self.subtitle_path)  # Reload if needed
        self.subtitle_name = subtitle_name
        self.subtitle_list.clear()
        for i, text in data:
            item = QListWidgetItem()
            check = QCheckBox(f"{i}行: {text}")
            self.subtitle_list.addItem(item)
            self.subtitle_list.setItemWidget(item, check)
        self.progress_label.setText(f"字幕渲染完成.剪辑后输出到:{output_folder}/{subtitle_name}")

    @Slot(str)
    def on_load_error(self, error):
        self.progress_label.setText(f"字幕渲染出错: {error}")

    def select_all(self):
        for i in range(self.subtitle_list.count()):
            item = self.subtitle_list.item(i)
            check = self.subtitle_list.itemWidget(item)
            check.setChecked(True)

    def deselect_all(self):
        for i in range(self.subtitle_list.count()):
            item = self.subtitle_list.item(i)
            check = self.subtitle_list.itemWidget(item)
            check.setChecked(False)

    def invert_selection(self):
        for i in range(self.subtitle_list.count()):
            item = self.subtitle_list.item(i)
            check = self.subtitle_list.itemWidget(item)
            check.setChecked(not check.isChecked())

    def clear_all(self):
        self.video_path = None
        self.subtitle_path = None
        self.subtitles = None
        self.subtitle_name = None
        self.selected_lines = []
        self.video_label.setText("未选视频")
        self.subtitle_label.setText("未选字幕")
        self.subtitle_list.clear()
        self.progress_label.setText("")
        self.clip_btn.setText("开始剪辑")
        self.is_clipping = False
        self.total_clips = 0
        self.completed_clips = 0
        self.failed_clips = []
        self.output_mode.setCurrentIndex(0)
        self.active_tasks = 0
        self.open_button.hide()

    def start_clipping(self):
        if self.is_clipping:
            self.stop_clipping()
            return

        if not self.video_path or not self.subtitle_name:
            self.progress_label.setText("请选择待剪辑视频及对应字幕文件.")
            return

        self.selected_lines = []
        for i in range(self.subtitle_list.count()):
            item = self.subtitle_list.item(i)
            check = self.subtitle_list.itemWidget(item)
            if check.isChecked():
                self.selected_lines.append(i + 1)  # 1-based

        if not self.selected_lines:
            self.progress_label.setText("至少请选中一行字幕.")
            return

        mode = self.output_mode.currentIndex()

        self.is_clipping = True
        self.clip_btn.setText("立即停止")
        self.total_clips = len(self.selected_lines)
        self.completed_clips = 0
        self.failed_clips = []
        self.open_button.show()
        self.active_tasks = self.total_clips
        self.progress_label.setText(f"共: {self.total_clips}, 完成: 0, 失败: 0")

        for line_num in self.selected_lines:
            sub = self.subtitles[line_num - 1]
            task = ClipTask(self.video_path, sub, line_num, self.subtitle_name, self.signals, mode)
            self.thread_pool.start(task)


    def stop_clipping(self):
        self.thread_pool.clear()
        self.is_clipping = False
        self.clip_btn.setText("开始剪辑")
        self.progress_label.setText("立即停止.")
        self.active_tasks = 0

    def update_progress(self, message):
        if "Completed" in message:
            self.completed_clips += 1
        elif "Failed" in message:
            self.failed_clips.append(message)
        self.active_tasks -= 1
        self.progress_label.setText(
            f"共: {self.total_clips}, 完成: {self.completed_clips}, "
            f"失败: {len(self.failed_clips)}\n" + "\n".join(self.failed_clips)
        )
        if self.active_tasks <= 0 and self.is_clipping:
            self.signals.finished.emit()

    def clipping_finished(self):
        self.is_clipping = False
        self.clip_btn.setText("开始剪辑")
        self.active_tasks = 0


    def open_output_folder(self):
        output_dir = f'{output_folder}/{self.subtitle_name}'
        QDesktopServices.openUrl(QUrl.fromLocalFile(output_dir))

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

希望这个实例能为你下次构建桌面应用时,在处理并发和线程通信方面提供一些有用的参考。

相关推荐
凌晨一点的秃头猪2 小时前
Python 常见 bug 总结和异常处理
开发语言·python·bug
新子y3 小时前
【小白笔记】input() 和 print() 这两个函数
笔记·python
文火冰糖的硅基工坊3 小时前
[人工智能-大模型-72]:模型层技术 - 模型训练六大步:①数据预处理 - 基本功能与对应的基本组成函数
开发语言·人工智能·python
Python×CATIA工业智造5 小时前
Pycatia二次开发基础代码解析:组件识别、选择反转与链接创建技术解析
python·pycharm
小宁爱Python5 小时前
从零搭建 RAG 智能问答系统 6:Text2SQL 与工作流实现数据库查询
数据库·人工智能·python·django
m0_748241235 小时前
Java注解与反射实现日志与校验
java·开发语言·python
可触的未来,发芽的智生6 小时前
追根索源:换不同的词嵌入(词向量生成方式不同,但词与词关系接近),会出现什么结果?
javascript·人工智能·python·神经网络·自然语言处理
hu_nil6 小时前
LLMOps-第十一周作业
python·vllm
阿Q说代码7 小时前
IPIDEA实现数据采集自动化:高效自动化采集方案
运维·python·自动化·数据采集