QMovie does not start if started from a thread

fellow developers! I have a question regarding Qt and multithreading.

=== SHORT VERSION ===========================================

Is it possible to do what I want with Qt? That is (1) show a loader; (2) download a gif in the background; (3) show the downloaded gif in the main window after it has been downloaded?

=== LONG VERSION ============================================

I have this idea that when I push a button, it:

  1. shows a loader;
  2. activates a thread that downloads a gif from the web;
  3. replaces the default gif hidden in the main window with the downloaded one and shows it
  4. hides the loader;

The problem that I'm experiencing is that when the downloaded gif is show, it is "frozen" or just the first frame is shown. Other than that everything is fine. However, it need the gif to be animated after the loader is hidden.

It is mentioned here that

So the Qt event loop is responsible for executing your code in response to various things that happen in your program, but while it is executing your code, it can't do anything else.

I believe this is the heart of the problem. It is also suggested that

It is recommended to use a QThread with Qt rather than a Python thread, but a Python thread will work fine if you don't ever need to communicate back to the main thread from your function.

Since my thread replaced the contents of the default gif, I believe that it does communicates back :(

Please find my code below :)

import sys
from time import sleep

from PyQt5.QtCore import QThread
from PyQt5.QtGui import QMovie
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel


class ChangeGif(QThread):
    """
    A worker that:
    1 - waits for 1 second;
    2 - changes the content of the default gif;
    3 - shows the default gif with new contents and hide the loader.
    """

    def __init__(self, all_widgets):
        QThread.__init__(self)
        self.all = all_widgets

    def run(self):
        sleep(1)
        self.new_gif_file = QMovie("files/new.gif")  # This is the "right" gif that freezes :(
        self.new_gif_file.start()
        self.all.gif_label.setMovie(self.new_gif_file)
        self.all.gif_label.show()
        self.all.loader_label.hide()


class MainWindow(QWidget):
    """
    Main window that shows a button. If you push the button, the following happens:
    1 - a loader is shown;
    2 - a thread in background is started;
    3 - the thread changes the contents of the default gif;
    4 - when the gif is replaced, the loader disappears and the default gif with the new content is shown
    """
    def __init__(self):
        super(MainWindow, self).__init__()

        # BUTTON
        self.button = QPushButton("Push me", self)
        self.button.setFixedSize(100, 50)
        self.button.clicked.connect(lambda: self.change_gif())

        # DEFAULT GIF
        self.gif_file = QMovie("files/default.gif")  # This is the "wrong" gif
        self.gif_file.start()
        self.gif_label = QLabel(self)
        self.gif_label.setMovie(self.gif_file)
        self.gif_label.move(self.button.width(), 0)
        self.gif_label.hide()

        # LOADER
        self.loader_file = QMovie("files/loader.gif")
        self.loader_file.start()
        self.loader_label = QLabel(self)
        self.loader_label.setMovie(self.loader_file)
        self.loader_label.move(self.button.width(), 0)
        self.loader_label.hide()

        # WINDOW SETTINGS
        self.setFixedSize(500, 500)
        self.show()

    def change_gif(self):
        self.loader_label.show()
        self.worker = ChangeGif(self)
        self.worker.start()


app = QApplication(sys.argv)
window = MainWindow()
app.exec_()

Upvotes: 1

Views: 1736

Answers (2)

musicamante
musicamante

Reputation: 48424

Other than the fact that no UI elements should be ever accessed or created outside the main Qt thread, this is valid also for UI elements that use objects created in other threads.

In your specific case, this not only means that you cannot set the movie in the separate thread, but you cannot create the QMovie there either.

In the following example, I'm opening a local file, and use a signal to send the data to the main thread. From there, I create a QBuffer to store the data in a IO device that can be used by QMovie. Note that both the buffer and the movie must have a persistent reference, otherwise they will be garbage collected as soon as the function returns.

from PyQt5.QtCore import QThread, QByteArray, QBuffer

class ChangeGif(QThread):
    dataLoaded = pyqtSignal(QByteArray)
    def __init__(self, all_widgets):
        QThread.__init__(self)
        self.all = all_widgets

    def run(self):
        sleep(1)
        f = QFile('new.gif')
        f.open(f.ReadOnly)
        self.dataLoaded.emit(f.readAll())
        f.close()


class MainWindow(QWidget):
    # ...
    def change_gif(self):
        self.loader_label.show()
        self.worker = ChangeGif(self)
        self.worker.dataLoaded.connect(self.applyNewGif)
        self.worker.start()

    def applyNewGif(self, data):
        # a persistent reference must be kept for both the buffer and the movie, 
        # otherwise they will be garbage collected, causing the program to 
        # freeze or crash
        self.buffer = QBuffer()
        self.buffer.setData(data)
        self.newGif = QMovie()
        self.newGif.setCacheMode(self.newGif.CacheAll)
        self.newGif.setDevice(self.buffer)
        self.gif_label.setMovie(self.newGif)
        self.newGif.start()
        self.gif_label.show()
        self.loader_label.hide()

Note that the example above is just for explanation purposes, as the downloading process can be done using QtNetwork modules, which work asynchronously and provide simple signals and slots to download remote data:

from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest

class MainWindow(QWidget):
    def __init__(self):
        # ...
        self.downloader = QNetworkAccessManager()

    def change_gif(self):
        self.loader_label.show()
        url = QUrl('https://path.to/animation.gif')
        self.device = self.downloader.get(QNetworkRequest(url))
        self.device.finished.connect(self.applyNewGif)

    def applyNewGif(self):
        self.loader_label.hide()
        self.newGif = QMovie()
        self.newGif.setDevice(self.device)
        self.gif_label.setMovie(self.newGif)
        self.newGif.start()
        self.gif_label.show()

Upvotes: 2

Vasilij
Vasilij

Reputation: 1941

The main rule working with Qt is that only one main thread is responsible for manipulating UI widgets. It is often referred to as GUI thread. You should never try to access widgets from another threads. For example, Qt timers won't start activated from another thread and Qt would print warning in the runtime console. In your case - if you put all the manipulation with the QMovie in the GUI Thread, most probably everything will work as expected.

What to do? Use the signals and slots - they are also designed to work between threads.

What your code should do:

  1. Show a loader form the main thread.
  2. Activate a thread that downloads a gif from the web.
  3. After the download is ready - emit a signal and capture it in main GUI thread'. Remember to use Qt::QueuedConnection` when connecting the signal and the slot, though it will be used automatically in some cases.
  4. In the receiving slot replace the default gif in main window with the downloaded one and show it and hide the loader.

You'll have to use some synchronization mechanism to avoid data-racing. A mutex will be enough. Or you could pass the data as the signal-slot parameter, but in case of a gif it probably would be too big.

Upvotes: 1

Related Questions