PAYATHON 2026

MoviePy 渲染时 Tkinter 进度条不更新,如何正确使用线程?

数据阿明

让 Tkinter 在主线程运行,把 MoviePy 的渲染工作放到后台线程。MyUILogger 只负责把进度写入 Queue,主线程通过 after 定期读取队列并更新控件。不要在 Tkinter 回调中等待渲染线程的 join()。

为什么窗口会停在 0%?

Tkinter 需要持续处理事件,才能重绘窗口、更新文字和响应操作。

第一种写法中:

windowProgressBar.after(100, Render)

这表示稍后由 Tkinter 的事件循环执行 Render,并不会创建后台线程。Render 一旦调用耗时的 write_videofile,主线程就无法继续处理界面事件。即使 logger 回调已经执行,终端也打印了新进度,窗口仍没有机会重绘。

第二种写法还有几个问题:

  • 在主线程中调用 thread2.join() 会等待渲染结束,继续阻塞界面。
  • 在渲染线程的 logger 回调中创建或修改 Tkinter 控件,会引入跨线程 UI 操作问题。这些操作应统一交给创建窗口的主线程处理。
  • ProgressBar 函数只读取一次 percentage,无法持续接收进度;线程执行顺序也不能保证读取时 percentage 已经赋值。
  • 每次回调都创建一个 Label,会不断叠加控件。应只创建一次,然后更新它的 text 或关联变量。

下面的赋值也有问题:

image_label2 = tk.Label(windowProgressBar, text=0).place(x=110, y=80)

place() 返回 None,因此 image_label2 没有保存 Label 对象。可以分开创建和布局,也可以使用 StringVar 更新文字。

用后台线程渲染,用队列传递进度

示例沿用问题中的 MoviePy 导入方式,演示单个视频的导出。运行前需要准备好输入文件,并确认输出路径可写。

import queue
import threading
import tkinter as tk
from tkinter import ttk

import proglog
from moviepy import VideoFileClip


class MyUILogger(proglog.ProgressBarLogger):
    def __init__(self, events):
        super().__init__()
        self.events = events
        self.last_percent = {}

    def bars_callback(self, bar, attr, value, old_value=None):
        # 仅在进度索引变化时计算百分比。
        if attr != "index":
            return

        total = self.bars[bar].get("total")
        if total is None or total <= 0:
            return

        percentage = max(0.0, min(100.0, value / total * 100.0))
        whole_percent = int(percentage)

        # 按进度条名称去重,减少队列消息数量。
        if self.last_percent.get(bar) == whole_percent:
            return

        self.last_percent[bar] = whole_percent
        self.events.put(("progress", bar, percentage))


def render_video(events):
    # 此函数在后台线程运行,不访问任何 Tkinter 对象。
    try:
        logger = MyUILogger(events)
        with VideoFileClip("Test Short chip.mp4") as clip:
            clip.write_videofile(
                "Test 2 F002.mp4",
                fps=clip.fps,
                logger=logger,
            )
    except Exception as exc:
        events.put(("error", str(exc)))
    else:
        events.put(("done",))


def main():
    root = tk.Tk()
    root.title("渲染进度")
    root.geometry("430x170")

    events = queue.Queue()
    rendering = False
    status = tk.StringVar(value="等待开始")

    ttk.Label(root, textvariable=status).pack(pady=(18, 10))

    progress = ttk.Progressbar(
        root,
        mode="determinate",
        maximum=100,
        length=360,
    )
    progress.pack(pady=5)

    def start_render():
        nonlocal rendering
        if rendering:
            return

        rendering = True
        start_button.configure(state="disabled")
        progress["value"] = 0
        status.set("正在准备渲染……")

        worker = threading.Thread(
            target=render_video,
            args=(events,),
        )
        worker.start()
        # 不在这里调用 worker.join()。

    def poll_events():
        nonlocal rendering

        # 限制每次处理的消息数,让事件循环有机会处理其他事件。
        for _ in range(200):
            try:
                event = events.get_nowait()
            except queue.Empty:
                break

            kind = event[0]
            if kind == "progress":
                _, bar, percentage = event
                progress["value"] = percentage
                status.set(f"{bar}: {percentage:.1f}%")
            elif kind == "done":
                rendering = False
                progress["value"] = 100
                status.set("渲染完成")
                start_button.configure(state="normal")
            elif kind == "error":
                rendering = False
                status.set(f"渲染失败:{event[1]}")
                start_button.configure(state="normal")

        root.after(50, poll_events)

    def close_window():
        if rendering:
            status.set("正在渲染,请完成后再关闭窗口")
            return
        root.destroy()

    start_button = ttk.Button(root, text="开始渲染", command=start_render)
    start_button.pack(pady=12)

    root.protocol("WM_DELETE_WINDOW", close_window)
    root.after(50, poll_events)
    root.after(100, start_render)
    root.mainloop()


if __name__ == "__main__":
    main()

render_video 负责打开视频、导出并释放资源,MyUILogger 负责发送进度,poll_events 负责更新界面。后台线程不接触 Tkinter 控件,也无需共享全局 percentage。

示例按进度条名称和整数百分比去重,界面只在百分比跨过整数时收到更新。可以调整这一条件,但没必要每一帧都刷新窗口。

进度达到 100% 是否代表文件已经导出完成?

不一定。MoviePy 的 logger 可能报告多个阶段的进度,例如音频处理和视频处理。不同的 bar 各有自己的 total,因此某个 bar 达到 100%,只表示该进度条对应的阶段结束。

示例显示的是 bar 名称和当前阶段的百分比,阶段切换时,数值可能从较高值回到较低值。这不表示进度计算出错,也不能直接解释为整个任务倒退。

只有 write_videofile 正常返回、示例中的资源清理完成后,后台线程才会发送 done,界面才显示“渲染完成”。如果需要整个任务的总百分比连续递增,就需要另外设计阶段权重;仅凭当前 bar 的 value / total 无法得到准确的整体进度。

原代码还需要调整哪些地方?

如果只导出一个视频,用示例中的 clip.write_videofile 就够了。如果需要组合多个片段,CompositeVideoClip 接收的是片段列表,单个片段也应写成列表:

final_clip = CompositeVideoClip([clip])

实际使用合成片段时,也要安排好源片段和合成片段的 close(),包括发生异常时的清理。

原始代码中的函数、类和方法还缺少有效缩进,也没有展示 mainloop()。如果实际文件也是这样,需要先修正这些基础结构。上面的示例包含完整的程序入口和事件循环。

如果进度窗口是现有 Tkinter 视频编辑器的一部分,应沿用现有的 Tk() 和 mainloop(),用 tk.Toplevel 创建进度窗口,并在现有主线程中更新。

示例没有实现取消渲染,因此渲染期间会暂缓关闭窗口。关闭窗口、终止线程和安全取消导出是不同的操作,不要把设置 daemon=True 当作取消渲染的实现。

备注:内容仅供参考。