jaseg / python-mpv

Python interface to the awesome mpv media player
https://git.jaseg.de/python-mpv.git
Other
554 stars 69 forks source link

Stuck in threading on macOS #61

Open kpj opened 6 years ago

kpj commented 6 years ago

Hello,

consider the following code:

import mpv

_url = 'test.webm'
mpv = mpv.MPV()
mpv.loadfile(_url)
print('waiting')
mpv.wait_for_playback()
print('done')

On Linux, this will print waiting, then play test.webm, and finally print done as expected.

On macOS 10.12.6 using Python 3.6 and MPV 0.27 however, it only prints waiting and is then stuck forever while the Python-Rocket starts jumping in my Dock.

I looked into python-mpv's code, and for mpv.wait_for_playback() it is stuck in line 557, which is:

self._playback_cond.wait()  # _playback_cond is threading.Condition

Using mpv.wait_for_property('filename', lambda x: x is None) instead, makes it stuck in line 569, which is:

sema.acquire()  # sema is threading.Semaphore

The problem thus seems to somehow be related to the handling of the threading library. Do you have any suggestion what might be causing this?

This is also possibly a duplicate of #59.

jaseg commented 6 years ago

The semaphore is most likely working alright. That is part of python and that is probably well tested even on osx. The problem is most likely in the event handling. I'm a bit challenged here since I've never used osx and I don't have any osx machines available. Could you try running your script in gdb to get a stack trace of the event handler thread? See this comment for instructions.

jaseg commented 6 years ago

The bouncing launcher you mentioned might be evidence that libmpv tried to create a window, but for some reason did not succeed. This sounds vaguely similar to what @Shu-ji describes in this comment. Could you try the workaround described there (manually creating a PyQT window and passing the window ID to mpv)?

kpj commented 6 years ago

Thanks for your suggestions, I agree that a bug in Python's threading is highly unlikely.

Event Handler Stack Trace

Thread 3 received signal SIGTRAP, Trace/breakpoint trap. [Switching to Thread 0x1a03 of process 1513] 0x000000010000519c in ?? ()


I will try again when I have more time.

### Manual PyQT window
Is there a way of doing this with your python-mpv version?
jaseg commented 6 years ago

You can use the linked pyqt example almost unmodified:

#!/usr/bin/env python3
import mpv
import time
import os
import sys

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *

class Test(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.container = QWidget(self)
        self.setCentralWidget(self.container)
        self.container.setAttribute(Qt.WA_DontCreateNativeAncestors)
        self.container.setAttribute(Qt.WA_NativeWindow)
        player = mpv.MPV(wid=str(int(self.container.winId())),
                vo='x11',
                log_handler=print,
                loglevel='debug')
        player.play('test.webm')

app = QApplication(sys.argv)

import locale
locale.setlocale(locale.LC_NUMERIC, 'C')
win = Test()
win.show()

sys.exit(app.exec_())

You may not need the vo='x11'. The setlocale is necessary since pyqt apparently stomps all over that on load. This could also be solved by just import pyqt before mpv (which already includes that line).

kpj commented 6 years ago

Neat, your example works nicely and plays the video on macOS for me (after removing vo='x11',).

How is the wid argument handled in python-mpv? It seems to be part of extra_mpv_opts, which is then somehow processed in _mpv_set_option_string.

Do you think that python-mpv should take care of this QWidget-creation itself, in order to provide a fix for macOS? Or one could try to debug this even further, using gdb.

jaseg commented 6 years ago

Yep, wid is effectively passed in as a command line option. See also this upstream example.

According to the README of the upstream examples a caveat seems to be that this way of embedding is not very stable, so you might also try embedding via OpenGL instead. The callbacks are supported by python-mpv. Upstream issue #556 gives some detail on mpv GUI initialization on OSX, though it still doesn't explain the hang observed here.

I would like to avoid putting PyQT-specific code into python-mpv. Since getting that up and running is very simple (3loc) I'd keep it in the README examples for now. For now, I think two things to add there are a) an OpenGL callback example and b) a hint to OSX users to create their own window.

As for proper debugging, yes, that would be great. However, I don't have an Apple machine so you or one of the other OSX users would have to do most of that. And given what upstream issue #556 says we might well find out that maybe on OSX embedding is only supported if you bring your own window.

kpj commented 6 years ago

I agree, adding PyQT-specific code to handle macOS-specific problems does not seem sensible in this scenario. For now I'll try to use this work-around instead of diving into more in-depth debugging craziness.

Assuming that I want to use python-mpv's MPV object in another application (for me that would be here), i.e. I don't want the PyQT event loop blocking my main thread. What would be the best way of handling this case? At first, I thought about simply starting it in another thread (and then somehow try to access MPV's loadfile, etc. methods):

[..]

def foo():
    app = QApplication(sys.argv)

    import locale
    locale.setlocale(locale.LC_NUMERIC, 'C')
    win = Test()
    win.show()

    sys.exit(app.exec_())

threading.Thread(target=foo).start()

This however crashes:

2017-12-29 12:29:13.231 Python[21946:737607] *** Assertion failure in +[NSUndoManager _endTopLevelGroupings], /BuildRoot/Library/Caches/com.apple.xbs/Sources/Foundation/Foundation-1450.16/Foundation/Misc.subproj/NSUndoManager.m:361
2017-12-29 12:29:13.246 Python[21946:737607] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: '+[NSUndoManager(NSInternal) _endTopLevelGroupings] is only safe to invoke on the main thread.'

with the main point probably being: is only safe to invoke on the main thread.

Do you have a suggestion for this, or is it again some macOS-specific difficulty?

jaseg commented 6 years ago

According to the Qt doc, Qt must run on the application's main thread. So this is not a limitation of python-mpv, mpv or even PyQt. I know this is inconvenient, but the best solution to this is probably to move other python work to auxiliary threads and possibly use PyQts Qt threading bindings.

A more low-effort alternative would be to farm out Qt code to a separate process using the multiprocessing module. It sounds like that would be quite easy to implement in your use case.

kpj commented 6 years ago

I can indeed display the video using an extra multiprocessing process. The problem then is however, that I still need to access the MPV object in the main process.

The simplest code (which abuses concurrency quite heavily) I could come up with to achieve this (in theory), is this:

import sys
import multiprocessing

from PyQt5.QtWidgets import QWidget, QMainWindow, QApplication
from PyQt5.QtCore import Qt

import mpv

class Test(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.container = QWidget(self)
        self.setCentralWidget(self.container)
        self.container.setAttribute(Qt.WA_DontCreateNativeAncestors)
        self.container.setAttribute(Qt.WA_NativeWindow)
        self.window_id = str(int(self.container.winId()))

def foo(player):
    app = QApplication(sys.argv)
    win = Test()
    win.show()

    mpv._mpv_set_option_string(player, 'wid', win.window_id)

    sys.exit(app.exec_())

player = mpv.MPV()
multiprocessing.Process(target=foo, args=(player,)).start()
player.play('test.webm')

Unfortunately, it crashes:

objc[61728]: +[__NSPlaceholderDate initialize] may have been in progress in another thread when fork() was called.
objc[61728]: +[__NSPlaceholderDate initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug.

Playing around with arbitrary time.sleep calls or using multiprocessing.Manager has not helped me so far. Do you have a suggestion?

jaseg commented 6 years ago

Yes. You cannot create the mpv object in the parent process, since it initializes the mpv handle. When using multiprocessing, import mpv and create the MPV object in the child process, then pass commands to the child process either low-level using multiprocessing's pipes and queues or high-level using multiprocessing's managers and proxies.

kpj commented 6 years ago

Uuuff. This special treatment of macOS starts to become annoying :-P

Consider this thread-inside-a-process solution:

import sys
import time
import threading
import multiprocessing

from PyQt5.QtWidgets import QWidget, QMainWindow, QApplication
from PyQt5.QtCore import Qt

class MPVProxy:
    def __init__(self):
        # setup MPV
        self.pipe = multiprocessing.Pipe()
        multiprocessing.Process(
            target=self._run, args=(self.pipe,)).start()

    def __getattr__(self, cmd):
        def wrapper(*args, **kwargs):
            output_p, input_p = self.pipe
            input_p.send((cmd, args, kwargs))
        return wrapper

    def _run(self, pipe):
        class Test(QMainWindow):
            def __init__(self, parent=None):
                super().__init__(parent)
                self.container = QWidget(self)
                self.setCentralWidget(self.container)
                self.container.setAttribute(Qt.WA_DontCreateNativeAncestors)
                self.container.setAttribute(Qt.WA_NativeWindow)
                self.window_id = str(int(self.container.winId()))

        # setup QT window
        app = QApplication(sys.argv)
        win = Test()
        win.show()

        # initialize MPV
        import mpv
        player = mpv.MPV()
        mpv._mpv_set_option_string(
            player.handle,
            'wid'.encode('utf-8'), win.window_id.encode('utf-8'))

        # poll pipe
        def handle_pipe():
            output_p, input_p = pipe
            while True:
                try:
                    msg = output_p.recv()
                    cmd, args, kwargs = msg

                    try:
                        func = getattr(player, cmd)
                    except AttributeError:
                        print(f'Invalid command "{cmd}"')
                        continue

                    func(*args, **kwargs)
                except EOFError:
                    break
        threading.Thread(target=handle_pipe).start()

        # run QT main-loop
        sys.exit(app.exec_())

mp = MPVProxy()
time.sleep(2)
mp.non_existing_function()
time.sleep(2)
mp.play('test.webm')

It waits 2 seconds, prints 'Invalid command "non_existing_function"', waits another 2 seconds and then plays the movie.

I had to use the thread inside of the process, in order to poll the pipe and run the QT main-loop at the same time. Although this (I think) perfectly mirrors the interface of MPV, this solutions seems rather non-optimal to me. Would you have a suggestion how to improve it?

jaseg commented 6 years ago

Sorry for not actually improving your code, but I just played around a bit and came up with the solution below. So far I tested it on Linux and it works fine there.

The main advantage of that is that it's rather simple and it provides access to most methods on an MPV instance by just adding them to the exposed array. The main disadvantages are that all the magic functions taking callables as well as direct property access don't work. Property access must be emulated with player._set_property('loop', 'inf'). You could build your own multiprocessing.BaseProxy descendant improving that interface, but I don't think that'd be worth it.

#!/usr/bin/env python3
import mpv
import time
import os
import sys

import multiprocessing
from multiprocessing.managers import BaseManager, BaseProxy

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *

class MpvManager(BaseManager):
    pass

MpvManager.register('MPV', mpv.MPV,
    exposed=[
        'play',
        '_get_property',
        '_set_property'
    ])

class MpvWindowThing(QMainWindow):
    def __init__(self, manager, parent=None):
        super().__init__(parent)
        self.container = QWidget(self)
        self.setCentralWidget(self.container)
        self.container.setAttribute(Qt.WA_DontCreateNativeAncestors)
        self.container.setAttribute(Qt.WA_NativeWindow)
        print('Running as pid', os.getpid())
        player = manager.MPV(wid=str(int(self.container.winId())),
                vo='x11', # You may not need this
                log_handler=print,
                loglevel='debug')
        player._set_property('loop', 'inf')
        player.play('test.webm')

with MpvManager() as manager:
    app = QApplication(sys.argv)
    win = MpvWindowThing(manager)
    win.show()
    sys.exit(app.exec_())

sys.exit(0)
kpj commented 6 years ago

On macOS there seems to be a problem with manager.MPV. It gets stuck on v vo/opengl Initializing OpenGL backend 'cocoa' and never plays the video. If I replace it with mpv.MPV everything works as expected.

Furthermore, everything in your code happens in the main-process, right?

jaseg commented 6 years ago

Oh my. No, what happens is multiprocessing.manager.BaseManager creates a proxy thing for MPV such that when you do manager.MPV you get a proxy for a MPV object living inside a subprocess. This way you have the PyQT process running in the parent process and mpv running in the subprocess. I have no idea why that doesn't work on mac.

I'm sorry I don't have any better suggestions. All ways I could think of to replace your custom multiprocessing.Pipe logic with the autogenerated proxies from multirprocessing.managers.BaseManager are ugly as sin as multiprocessing really does not have a very good or flexible API. I'd definitely go for your way now that I've had a read through multiprocessing's source.

I guess your MPVProxy really is fine. I'd maybe factor out the receive loop into another class. 50loc for that sort of workaround is ok I guess.

kpj commented 6 years ago

I see. I now ended up with this implementation.

It's not as flexible as I want it to be (e.g. attribute setting doesn't work properly yet, setting time-observers fails as lambdas cannot be pickled, ...), but it's a start.

devxpy commented 6 years ago

Looks like you guys are having some issues with multiprocessing.

May I suggest using my library, zproc?

Will try to cook something up myself.

P.S. Awesome library!

iwalton3 commented 4 years ago

If anyone is looking for a workaround to this, my external MPV library works on OSX. It implements a decent amount of the API of python-mpv, but it probably isn't a drop-in replacement. (It was originally implemented to allow MPV support on platforms where getting a build of libmpv1 is a pain.)

jb-alvarado commented 4 years ago

Hi, is there any new on this topic or an workaround? The PyQt solution is not really an option for me.

roachcord3 commented 2 years ago

@iwalton3 I actually gave your library a shot, and got some success. You may or may not find this interesting. To make a long story short, I had to shove the MpvEventID class from python-mpv into your imported library, and then it worked, for the most part. There were some BrokenPipeErrors and TypeErrors but it mostly worked (though, the intended experience of the Hydrus client is for the player to be embedded in Hydrus' own media viewer window, rather than have the player open as its own separate program. Still, it's progress.) Full writeup here: https://github.com/hydrusnetwork/hydrus/issues/1132#issuecomment-1248864461

roachcord3 commented 2 years ago

This issue has been bugging me so much that I have opened a $250 bounty for anyone who solves this (in a way that ends up also resolving the issue for hydrusnetwork/hydrus#1132, i.e., gets an embedded player working in a QWidget.)

FWIW, SMPlayer is a Qt based app that embeds MPV, apparently. I don't know what they do differently from python-mpv to make it work, but it's probably worth referencing their code: https://github.com/smplayer-dev/smplayer/blob/master/src/mplayerwindow.cpp

thorlucas commented 2 years ago

@roachcord3 I'm very glad you opened this bounty because I've been stuck with this issue for a while now.

0xa48rx394r83e9 commented 5 months ago

This issue has been bugging me so much that I have opened a $250 bounty for anyone who solves this (in a way that ends up also resolving the issue for hydrusnetwork/hydrus#1132, i.e., gets an embedded player working in a QWidget.)

FWIW, SMPlayer is a Qt based app that embeds MPV, apparently. I don't know what they do differently from python-mpv to make it work, but it's probably worth referencing their code: https://github.com/smplayer-dev/smplayer/blob/master/src/mplayerwindow.cpp

Hey, this is intriguing and something I'd be happy to look into, but first I'd need to know if a: this is still an issue (it's been two years) and b: if the bounty is still active.

roachcord3 commented 5 months ago

Hey, this is intriguing and something I'd be happy to look into, but first I'd need to know if a: this is still an issue (it's been two years) and b: if the bounty is still active.

Yes, it's still an issue. However, I don't know whether that boss bounty system still works. I logged into my dashboard and see my bounties are still active, but the claim link shows that they are having some error. So, you might want to contact the owner of boss bounty about it if the error persists.

dude-k commented 5 months ago

The original poster's example from Dec 2017 still doesn't work so, yes, this is still an issue. A fix that gets the example to work (or an example that's simpler than the QT approach) would be greatly appreciated. Thanks for looking into it.

I could probably toss USD10 or something into a pot if you need a monetary motivation.

jaseg commented 5 months ago

From my end I can promise credit and thanks in the readme to whoever manages to solve this. I still don't have a mac, so I still can't work on this myself.