diff options
Diffstat (limited to 'voctocore')
-rw-r--r-- | voctocore/default-config.ini | 4 | ||||
-rwxr-xr-x | voctocore/experiments/test-grabber-src.sh | 4 | ||||
-rwxr-xr-x | voctocore/experiments/video-grabber-src.sh | 6 | ||||
-rw-r--r-- | voctocore/lib/config.py | 5 | ||||
-rw-r--r-- | voctocore/lib/controlserver.py | 9 | ||||
-rw-r--r-- | voctocore/lib/helper.py | 12 | ||||
-rw-r--r-- | voctocore/lib/pipeline.py | 405 | ||||
-rw-r--r-- | voctocore/lib/quadmix.py | 172 | ||||
-rw-r--r-- | voctocore/lib/shmsrc.py | 116 | ||||
-rw-r--r-- | voctocore/lib/videomix.py | 347 | ||||
-rwxr-xr-x | voctocore/voctocore.py | 77 |
11 files changed, 788 insertions, 369 deletions
diff --git a/voctocore/default-config.ini b/voctocore/default-config.ini index f9d90ee..f00f91d 100644 --- a/voctocore/default-config.ini +++ b/voctocore/default-config.ini @@ -2,7 +2,9 @@ video=cam1,cam2,grabber audio=cam1,cam2 -videocaps=video/x-raw,format=RGBx,width=1280,height=720,framerate=25/1 +socketpath=/tmp/voctomix-sockets + +videocaps=video/x-raw,format=RGBx,width=1280,height=720,framerate=25/1,pixel-aspect-ratio=1/1 audiocaps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 [pause] diff --git a/voctocore/experiments/test-grabber-src.sh b/voctocore/experiments/test-grabber-src.sh index 7978b69..9ef15d5 100755 --- a/voctocore/experiments/test-grabber-src.sh +++ b/voctocore/experiments/test-grabber-src.sh @@ -1,10 +1,10 @@ #!/bin/sh gst-launch-1.0 -vm \ - videotestsrc !\ + videotestsrc pattern=ball !\ video/x-raw,width=1280,height=720,framerate=25/1,format=RGBx !\ queue !\ shmsink \ sync=true \ - socket-path=/tmp/grabber-v \ + socket-path=/tmp/voctomix-sockets/v-cam1 \ wait-for-connection=false \ shm-size=100000000 diff --git a/voctocore/experiments/video-grabber-src.sh b/voctocore/experiments/video-grabber-src.sh index 02ba1b9..e592940 100755 --- a/voctocore/experiments/video-grabber-src.sh +++ b/voctocore/experiments/video-grabber-src.sh @@ -1,5 +1,5 @@ #!/bin/sh -gst-launch-1.0 -v \ +gst-launch-1.0 \ uridecodebin \ uri=http://video.blendertestbuilds.de/download.blender.org/ED/ED_1280.avi \ name=src \ @@ -13,7 +13,7 @@ gst-launch-1.0 -v \ video/x-raw,format=RGBx,width=1280,height=720,framerate=25/1 !\ shmsink \ sync=true \ - socket-path=/tmp/grabber-v \ + socket-path=/tmp/voctomix-sockets/v-cam1 \ wait-for-connection=false \ shm-size=100000000 \ @@ -24,6 +24,6 @@ gst-launch-1.0 -v \ audio/x-raw,format=S16LE,layout=interleaved,rate=44100,channels=2 !\ shmsink \ sync=true \ - socket-path=/tmp/grabber-a \ + socket-path=/tmp/voctomix-sockets/a-cam1 \ wait-for-connection=false \ shm-size=10000000 diff --git a/voctocore/lib/config.py b/voctocore/lib/config.py index b4c64aa..76f67b0 100644 --- a/voctocore/lib/config.py +++ b/voctocore/lib/config.py @@ -1,6 +1,11 @@ import os.path from configparser import SafeConfigParser +def getlist(self, section, option): + return [x.strip() for x in self.get(section, option).split(',')] + +SafeConfigParser.getlist = getlist + Config = SafeConfigParser() Config.read([ 'default-config.ini', diff --git a/voctocore/lib/controlserver.py b/voctocore/lib/controlserver.py index e11d6a8..fffeda8 100644 --- a/voctocore/lib/controlserver.py +++ b/voctocore/lib/controlserver.py @@ -1,4 +1,4 @@ -import socket, threading, queue +import socket, threading, queue, logging from gi.repository import GObject def controlServerEntrypoint(f): @@ -7,6 +7,7 @@ def controlServerEntrypoint(f): return f class ControlServer(): + log = logging.getLogger('ControlServer') def __init__(self, videomix): '''Initialize server and start listening.''' self.videomix = videomix @@ -22,7 +23,7 @@ class ControlServer(): def listener(self, sock, *args): '''Asynchronous connection listener. Starts a handler for each connection.''' conn, addr = sock.accept() - print("Connection from ", addr) + self.log.info("Connection from %s", addr) # register data-received handler inside the GTK-Mainloop GObject.io_add_watch(conn, GObject.IO_IN, self.handler) @@ -32,7 +33,7 @@ class ControlServer(): '''Asynchronous connection handler. Processes each line from the socket.''' line = conn.recv(4096) if not len(line): - print("Connection closed.") + self.log.debug("Connection closed.") return False r = self.processLine(line.decode('utf-8')) @@ -49,7 +50,7 @@ class ControlServer(): def processLine(self, line): command, argstring = (line.strip()+' ').split(' ', 1) args = argstring.strip().split() - print(command, args) + self.log.info(command % args) if not hasattr(self.videomix, command): return 'unknown command {}'.format(command) diff --git a/voctocore/lib/helper.py b/voctocore/lib/helper.py new file mode 100644 index 0000000..ac9bfe5 --- /dev/null +++ b/voctocore/lib/helper.py @@ -0,0 +1,12 @@ +from gi.repository import Gst + +def iteratorHelper(it): + while True: + result, value = it.next() + if result == Gst.IteratorResult.DONE: + break + + if result != Gst.IteratorResult.OK: + raise IteratorError(result) + + yield value diff --git a/voctocore/lib/pipeline.py b/voctocore/lib/pipeline.py new file mode 100644 index 0000000..aa9754c --- /dev/null +++ b/voctocore/lib/pipeline.py @@ -0,0 +1,405 @@ +#!/usr/bin/python3 +import os, errno, time, logging +from gi.repository import GLib, Gst + +# import controlserver annotation +from lib.controlserver import controlServerEntrypoint + +# import library components +from lib.config import Config +from lib.quadmix import QuadMix +# from lib.videomix import VideoMix +# from lib.audiomix import AudioMix +# from lib.distributor import TimesTwoDistributor +from lib.shmsrc import FailsafeShmSrc + +class Pipeline(Gst.Pipeline): + """mixing, streaming and encoding pipeline constuction and control""" + log = logging.getLogger('Pipeline') + videonames = [] + audionames = [] + + def __init__(self): + super().__init__() + + self.log.debug('Creating Video-Mixer') + # create audio and video mixer + self.quadmixer = QuadMix() + self.add(self.quadmixer) + + # self.videomixer = VideoMix() + # self.add(self.videomixer) + + # self.audiomixer = AudioMix() + # self.add(self.audiomixer) + + # read the path where the shm-control-sockets are located and ensure it exists + socketpath = Config.get('sources', 'socketpath') + self.log.info('Ensuring the configured socketpath exists: %s', socketpath) + try: + os.makedirs(socketpath) + except OSError as exception: + if exception.errno != errno.EEXIST: + raise + + self.videonames = Config.getlist('sources', 'video') + self.audionames = Config.getlist('sources', 'video') + + for name in self.videonames: + socket = os.path.join(socketpath, 'v-'+name) + + self.log.info('Creating video-source %s at socket-path %s', name, socket) + sourcebin = FailsafeShmSrc(name, socket) + self.add(sourcebin) + self.quadmixer.add_source(sourcebin) + + # distributor = TimesTwoDistributor(sourcebin) + # self.add(distributor) + + # distributor.link(self.quadmixer) + # distributor.link(self.videomixer) + + # for audiosource in Config.getlist('sources', 'audio'): + # sourcebin = FailsafeShmSrc(os.path.join(socketpath, audiosource)) + + # self.add(sourcebin) + # sourcebin.link(self.audiomixer) + + # tell the quadmix that this were all sources and no more sources will come after this + self.quadmixer.finalize() + + self.quadmixsink = Gst.ElementFactory.make('autovideosink', 'quadmixsink') + self.quadmixsink.set_property('sync', False) + self.add(self.quadmixsink) + self.quadmixer.link(self.quadmixsink) + + # self.videosink = Gst.ElementFactory.make('autovideosink', 'videosink') + # self.add(self.videosink) + # self.videomixer.link(self.videosink) + + # self.audiosink = Gst.ElementFactory.make('autoaudiosink', 'audiosink') + # self.add(self.audiosink) + # self.audiomixer.link(self.audiosink) + + def run(self): + self.set_state(Gst.State.PAUSED) + time.sleep(0.5) + self.set_state(Gst.State.PLAYING) + + def quit(self): + self.set_state(Gst.State.NULL) + + + + + # # collection of video-sources to connect to the quadmix + # quadmixSources = [] + + # # create camera sources + # for camberabin in self.createDummyCamSources(): + # # link camerasource to audiomixer + # camberabin.get_by_name('audio_src').link(self.pipeline.get_by_name('liveaudio')) + + # # inject a ×2 distributor and link one end to the live-mixer + # distributor = self.createDistributor(camberabin.get_by_name('video_src'), camberabin.get_name()) + # distributor.get_by_name('a').link(self.pipeline.get_by_name('livevideo')) + + # # collect the other end to add it later to the quadmix + # quadmixSources.append(distributor.get_by_name('b')) + + # # TODO: generate pause & slides with another generator here which only + # # yields if the respective files are present and which only have a video-pad + + # # add all video-sources to the quadmix-monitor-screen + # self.addVideosToQuadmix(quadmixSources, self.pipeline.get_by_name('quadmix')) + + # # initialize to known defaults + # # TODO: make configurable + # self.switchVideo(0) + # self.switchAudio(0) + + # Gst.debug_bin_to_dot_file(self.pipeline, Gst.DebugGraphDetails.ALL, 'test') + # self.pipeline.set_state(Gst.State.PLAYING) + + + + + + + # def createMixer(self): + # """create audio and video mixer""" + # # create mixer-pipeline from string + # mixerbin = Gst.parse_bin_from_description(""" + # videomixer name=livevideo ! autovideosink + # input-selector name=liveaudio ! autoaudiosink + + # videotestsrc pattern="solid-color" foreground-color=0x808080 ! capsfilter name=filter ! videomixer name=quadmix ! autovideosink + # """, False) + + # # define caps for the videotestsrc which generates the background-color for the quadmix + # bgcaps = Gst.Caps.new_empty_simple('video/x-raw') + # bgcaps.set_value('width', round(self.monitorSize[0])) + # bgcaps.set_value('height', round(self.monitorSize[1])) + # mixerbin.get_by_name('filter').set_property('caps', bgcaps) + + # # name the bin, add and return it + # mixerbin.set_name('mixerbin') + # self.pipeline.add(mixerbin) + # return mixerbin + + # def addVideosToQuadmix(self, videosources, quadmix): + # """add all avaiable videosources to the quadmix""" + # count = len(videosources) + + # # coordinate of the cell where we place the next video + # place = [0, 0] + + # # number of cells in the quadmix-monitor + # grid = [0, 0] + # grid[0] = math.ceil(math.sqrt(count)) + # grid[1] = math.ceil(count / grid[0]) + + # # size of each cell in the quadmix-monitor + # cellSize = ( + # self.monitorSize[0] / grid[0], + # self.monitorSize[1] / grid[1] + # ) + + # print("showing {} videosources in a {}×{} grid in a {}×{} px window, which gives cells of {}×{} px per videosource".format( + # count, grid[0], grid[1], self.monitorSize[0], self.monitorSize[1], cellSize[0], cellSize[1])) + + # # iterate over all video-sources + # for idx, videosource in enumerate(videosources): + # # generate a pipeline for this videosource which + # # - scales the video to the request + # # - remove n px of the video (n = 5 if the video is highlighted else 0) + # # - add a colored border of n px of the video (n = 5 if the video is highlighted else 0) + # # - overlay the index of the video as text in the top left corner + # # - known & named output + # previewbin = Gst.parse_bin_from_description(""" + # videoscale name=in ! + # capsfilter name=caps ! + # videobox name=crop top=0 left=0 bottom=0 right=0 ! + # videobox fill=red top=-0 left=-0 bottom=-0 right=-0 name=add ! + # textoverlay color=0xFFFFFFFF halignment=left valignment=top xpad=10 ypad=5 font-desc="sans 35" name=text ! + # identity name=out + # """, False) + + # # name the bin and add it + # previewbin.set_name('previewbin-{}'.format(idx)) + # self.pipeline.add(previewbin) + # self.previewbins.append(previewbin) + + # # set the overlay-text + # previewbin.get_by_name('text').set_property('text', str(idx)) + + # # query the video-source caps and extract its size + # caps = videosource.get_static_pad('src').query_caps(None) + # capsstruct = caps.get_structure(0) + # srcSize = ( + # capsstruct.get_int('width')[1], + # capsstruct.get_int('height')[1], + # ) + + # # calculate the ideal scale factor and scale the sizes + # f = max(srcSize[0] / cellSize[0], srcSize[1] / cellSize[1]) + # scaleSize = ( + # srcSize[0] / f, + # srcSize[1] / f, + # ) + + # # calculate the top/left coordinate + # coord = ( + # place[0] * cellSize[0] + (cellSize[0] - scaleSize[0]) / 2, + # place[1] * cellSize[1] + (cellSize[1] - scaleSize[1]) / 2, + # ) + + # print("placing videosource {} of size {}×{} scaled by {} to {}×{} in a cell {}×{} px cell ({}/{}) at position ({}/{})".format( + # idx, srcSize[0], srcSize[1], f, scaleSize[0], scaleSize[1], cellSize[0], cellSize[1], place[0], place[1], coord[0], coord[1])) + + # # link the videosource to the input of the preview-bin + # videosource.link(previewbin.get_by_name('in')) + + # # create and set the caps for the preview-scaler + # scalecaps = Gst.Caps.new_empty_simple('video/x-raw') + # scalecaps.set_value('width', round(scaleSize[0])) + # scalecaps.set_value('height', round(scaleSize[1])) + # previewbin.get_by_name('caps').set_property('caps', scalecaps) + + # # request a pad from the quadmixer and configure x/y position + # sinkpad = quadmix.get_request_pad('sink_%u') + # sinkpad.set_property('xpos', round(coord[0])) + # sinkpad.set_property('ypos', round(coord[1])) + + # # link the output of the preview-bin to the mixer + # previewbin.get_by_name('out').link(quadmix) + + # # increment grid position + # place[0] += 1 + # if place[0] >= grid[0]: + # place[1] += 1 + # place[0] = 0 + + # def createDistributor(self, videosource, name): + # """create a simple ×2 distributor""" + # distributor = Gst.parse_bin_from_description(""" + # tee name=t + # t. ! queue name=a + # t. ! queue name=b + # """, False) + + # # set a name and add to pipeline + # distributor.set_name('distributor({0})'.format(name)) + # self.pipeline.add(distributor) + + # # link input to the tee + # videosource.link(distributor.get_by_name('t')) + # return distributor + + # def createDummyCamSources(self): + # """create test-video-sources from files or urls""" + + # # TODO make configurable + # uris = ('file:///home/peter/122.mp4', 'file:///home/peter/10025.mp4',) + # for idx, uri in enumerate(uris): + # # create a bin for a simulated camera input + # # force the input resolution to 1024x576 because that way the following elements + # # in the pipeline cam know the size even if the file is not yet loaded. the quadmixer + # # is not resize-capable + # camberabin = Gst.parse_bin_from_description(""" + # uridecodebin name=input + # input. ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1024,height=576,framerate=25/1 ! identity name=video_src + # input. ! audioconvert name=audio_src + # """, False) + + # # set name and uri + # camberabin.set_name('dummy-camberabin({0})'.format(uri)) + # camberabin.get_by_name('input').set_property('uri', uri) + + # # add to pipeline and pass the bin upstream + # self.pipeline.add(camberabin) + # yield camberabin + + + # def createCamSources(self): + # """create real-video-sources from the bmd-drivers""" + + # # TODO make number of installed cams configurable + # for cam in range(2): + # # create a bin for camera input + # camberabin = Gst.parse_bin_from_description(""" + # decklinksrc name=input input=sdi input-mode=1080p25 + # input. ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1920,height=1080,framerate=25/1 ! identity name=video_src + # input. ! audioconvert name=audio_src + # """, False) + + # # set name and subdevice + # camberabin.set_name('camberabin({0})'.format(cam)) + # camberabin.get_by_name('input').set_property('subdevice', cam) + + # # add to pipeline and pass the bin upstream + # self.pipeline.add(camberabin) + # yield camberabin + + + + + ### below are access-methods for the ControlServer + @controlServerEntrypoint + def status(self): + """System Status Query""" + raise NotImplementedError("status command is not implemented yet") + + @controlServerEntrypoint + def numAudioSources(self): + """return number of available audio sources""" + raise NotImplementedError("audio is not implemented yet") + + + @controlServerEntrypoint + def switchAudio(self, audiosource): + """switch audio to the selected audio""" + raise NotImplementedError("audio is not implemented yet") + + + @controlServerEntrypoint + def numVideoSources(self): + """return number of available video sources""" + livevideo = self.pipeline.get_by_name('livevideo') + return str(len(self.videonames)) + + + @controlServerEntrypoint + def switchVideo(self, videosource): + """switch audio to the selected video""" + if videosource.isnumeric(): + idx = int(videosource) + self.log.info("interpreted input as videosource-index %u", idx) + if idx >= len(self.videonames): + idx = None + else: + try: + idx = self.videonames.index(videosource) + self.log.info("interpreted input as videosource-name, lookup to %u", idx) + except IndexError: + idx = None + + if idx == None: + return 'unknown video-source: %s' % (videosource) + + self.log.info("switching quadmix to video-source %u", idx) + self.quadmixer.set_active(idx) + # todo: switch main switcher + + @controlServerEntrypoint + def fadeVideo(self, videosource): + """fade video to the selected video""" + raise NotImplementedError("fade command is not implemented yet") + + + @controlServerEntrypoint + def setPipVideo(self, videosource): + """switch video-source in the PIP to the selected video""" + raise NotImplementedError("pip commands are not implemented yet") + + + @controlServerEntrypoint + def fadePipVideo(self, videosource): + """fade video-source in the PIP to the selected video""" + raise NotImplementedError("pip commands are not implemented yet") + + + class PipPlacements: + """enumeration of possible PIP-Placements""" + TopLeft, TopRight, BottomLeft, BottomRight = range(4) + + + @controlServerEntrypoint + def setPipPlacement(self, placement): + """place PIP in the selected position""" + assert(isinstance(placement, PipPlacements)) + raise NotImplementedError("pip commands are not implemented yet") + + + @controlServerEntrypoint + def setPipStatus(self, enabled): + """show or hide PIP""" + raise NotImplementedError("pip commands are not implemented yet") + + + @controlServerEntrypoint + def fadePipStatus(self, enabled): + """fade PIP in our out""" + raise NotImplementedError("pip commands are not implemented yet") + + + class StreamContents: + """enumeration of possible PIP-Placements""" + Live, Pause, NoStream = range(3) + + + @controlServerEntrypoint + def selectStreamContent(self, content): + """switch the livestream-content between selected mixer output, pause-image or nostream-imag""" + assert(isinstance(content, StreamContents)) + raise NotImplementedError("pause/nostream switching is not implemented yet") diff --git a/voctocore/lib/quadmix.py b/voctocore/lib/quadmix.py new file mode 100644 index 0000000..5115919 --- /dev/null +++ b/voctocore/lib/quadmix.py @@ -0,0 +1,172 @@ +#!/usr/bin/python3 +import math, logging +from gi.repository import GLib, Gst + +from lib.helper import iteratorHelper +from lib.config import Config + +class QuadMix(Gst.Bin): + log = logging.getLogger('QuadMix') + sources = [] + previewbins = [] + + def __init__(self): + super().__init__() + + caps = Gst.Caps.from_string(Config.get('mix', 'monitorcaps')) + self.log.debug('parsing monitorcaps from config: %s', caps.to_string()) + struct = caps.get_structure(0) + + self.monitorSize = [struct.get_int('width')[1], struct.get_int('height')[1]] + + self.bgsrc = Gst.ElementFactory.make('videotestsrc', 'bgsrc') + self.mixer = Gst.ElementFactory.make('videomixer', 'mixer') + self.scale = Gst.ElementFactory.make('videoscale', 'scale') + + self.add(self.bgsrc) + self.add(self.mixer) + self.add(self.scale) + + self.bgsrc.link_filtered(self.mixer, caps) + self.mixer.link_filtered(self.scale, caps) + + self.bgsrc.set_property('pattern', 'solid-color') + self.bgsrc.set_property('foreground-color', 0x808080) + + self.add_pad( + Gst.GhostPad.new('src', self.scale.get_static_pad('src')) + ) + + # I don't know how to create a on-request ghost-pad + def add_source(self, src): + self.log.info('adding source %s', src.get_name()) + self.sources.append(src) + + def finalize(self): + self.log.debug('all sources added, calculating layout') + + # number of placed sources + count = len(self.sources) + + # coordinate of the cell where we place the next video + place = [0, 0] + + # number of cells in the quadmix-monitor + grid = [0, 0] + grid[0] = math.ceil(math.sqrt(count)) + grid[1] = math.ceil(count / grid[0]) + + # size of each cell in the quadmix-monitor + cellSize = ( + self.monitorSize[0] / grid[0], + self.monitorSize[1] / grid[1] + ) + + # report calculation results + self.log.info('showing %u videosources in a %u×%u grid in a %u×%u px window, which gives cells of %u×%u px per videosource', + count, grid[0], grid[1], self.monitorSize[0], self.monitorSize[1], cellSize[0], cellSize[1]) + + # iterate over all video-sources + for idx, videosource in enumerate(self.sources): + # query the video-source caps and extract its size + caps = videosource.get_static_pad('src').query_caps(None) + capsstruct = caps.get_structure(0) + srcSize = ( + capsstruct.get_int('width')[1], + capsstruct.get_int('height')[1], + ) + + # calculate the ideal scale factor and scale the sizes + f = max(srcSize[0] / cellSize[0], srcSize[1] / cellSize[1]) + scaleSize = ( + srcSize[0] / f, + srcSize[1] / f, + ) + + # calculate the top/left coordinate + coord = ( + place[0] * cellSize[0] + (cellSize[0] - scaleSize[0]) / 2, + place[1] * cellSize[1] + (cellSize[1] - scaleSize[1]) / 2, + ) + + self.log.info('placing videosource %u of size %u×%u scaled by %u to %u×%u in a cell %u×%u px cell (%u/%u) at position (%u/%u)', + idx, srcSize[0], srcSize[1], f, scaleSize[0], scaleSize[1], cellSize[0], cellSize[1], place[0], place[1], coord[0], coord[1]) + + # request a pad from the quadmixer and configure x/y position + sinkpad = self.mixer.get_request_pad('sink_%u') + sinkpad.set_property('xpos', round(coord[0])) + sinkpad.set_property('ypos', round(coord[1])) + + # create a sub-preview-bin + previewbin = QuadMixPreview(idx, scaleSize) + self.add(previewbin) + self.previewbins.append(previewbin) + + # link videosource to input of previewbin + videosource.link(previewbin) + + # link the output of the preview-bin to the mixer + previewbin.get_static_pad('src').link(sinkpad) + + # increment grid position + place[0] += 1 + if place[0] >= grid[0]: + place[1] += 1 + place[0] = 0 + + def set_active(self, target): + for idx, previewbin in enumerate(self.previewbins): + previewbin.set_active(target == idx) + +class QuadMixPreview(Gst.Bin): + log = logging.getLogger('QuadMixPreview') + strokeWidth = 5 + + def __init__(self, idx, scaleSize): + super().__init__() + + self.scale = Gst.ElementFactory.make('videoscale', 'scale') + self.cropbox = Gst.ElementFactory.make('videobox', 'cropbox') + self.strokebox = Gst.ElementFactory.make('videobox', 'strokebox') + self.textoverlay = Gst.ElementFactory.make('textoverlay', 'textoverlay') + + self.add(self.scale) + self.add(self.cropbox) + self.add(self.strokebox) + self.add(self.textoverlay) + + caps = Gst.Caps.new_empty_simple('video/x-raw') + caps.set_value('width', round(scaleSize[0])) + caps.set_value('height', round(scaleSize[1])) + + self.strokebox.set_property('fill', 'green') + + self.textoverlay.set_property('color', 0xFFFFFFFF) + self.textoverlay.set_property('halignment', 'left') + self.textoverlay.set_property('valignment', 'top') + self.textoverlay.set_property('xpad', 10) + self.textoverlay.set_property('ypad', 5) + self.textoverlay.set_property('font-desc', 'sans 35') + + self.scale.link_filtered(self.cropbox, caps) + self.cropbox.link(self.strokebox) + self.strokebox.link(self.textoverlay) + + self.set_active(False) + + # Add Ghost Pads + self.add_pad( + Gst.GhostPad.new('sink', self.scale.get_static_pad('sink')) + ) + self.add_pad( + Gst.GhostPad.new('src', self.textoverlay.get_static_pad('src')) + ) + + def set_active(self, active): + self.log.info("switching active-state to %u", active) + for side in ('top', 'left', 'right', 'bottom'): + self.cropbox.set_property(side, self.strokeWidth if active else 0) + self.strokebox.set_property(side, -self.strokeWidth if active else 0) + + def setColor(self, color): + self.strokebox.set_property('fill', color) diff --git a/voctocore/lib/shmsrc.py b/voctocore/lib/shmsrc.py new file mode 100644 index 0000000..53a7d9d --- /dev/null +++ b/voctocore/lib/shmsrc.py @@ -0,0 +1,116 @@ +#!/usr/bin/python3 +import time, logging +from gi.repository import GLib, Gst + +from lib.config import Config + +class FailsafeShmSrc(Gst.Bin): + log = logging.getLogger('FailsafeShmSrc') + last_buffer_arrived = 0 + is_in_failstate = True + + def __init__(self, name, socket): + super().__init__() + self.set_name(socket) + + caps = Gst.Caps.from_string(Config.get('sources', 'videocaps')) + self.log.debug('parsing videocaps from config: %s', caps.to_string()) + + # Create elements + self.shmsrc = Gst.ElementFactory.make('shmsrc', None) + self.identity1 = Gst.ElementFactory.make('identity', None) + self.identity2 = Gst.ElementFactory.make('identity', None) + self.switch = Gst.ElementFactory.make('input-selector', None) + self.failsrc = Gst.ElementFactory.make('videotestsrc', None) + + if not self.shmsrc or not self.identity1 or not self.identity2 or not self.switch or not self.failsrc: + self.log.error('could not create elements') + + # Add elements to Bin + self.add(self.shmsrc) + self.add(self.identity1) + self.add(self.identity2) + self.add(self.switch) + self.add(self.failsrc) + + # Get Switcher-Pads + self.goodpad = self.switch.get_request_pad('sink_%u') + self.failpad = self.switch.get_request_pad('sink_%u') + + # Set properties + self.shmsrc.set_property('socket-path', socket) + self.shmsrc.set_property('is-live', True) + self.shmsrc.set_property('do-timestamp', True) + self.identity2.set_property('sync', True) + self.switch.set_property('active-pad', self.failpad) + + # Link elements + self.shmsrc.link_filtered(self.identity1, caps) + self.identity1.get_static_pad('src').link(self.goodpad) + + self.failsrc.link_filtered(self.identity2, caps) + self.identity2.get_static_pad('src').link(self.failpad) + + # Install pad probes + self.shmsrc.get_static_pad('src').add_probe(Gst.PadProbeType.BLOCK | Gst.PadProbeType.EVENT_DOWNSTREAM, self.event_probe, None) + self.shmsrc.get_static_pad('src').add_probe(Gst.PadProbeType.BLOCK | Gst.PadProbeType.BUFFER, self.data_probe, None) + + # Install Watchdog + GLib.timeout_add(500, self.watchdog) + + # Add Ghost Pads + self.add_pad( + Gst.GhostPad.new('src', self.switch.get_static_pad('src')) + ) + + def do_handle_message(self, msg): + if msg.type == Gst.MessageType.ERROR and msg.src == self.shmsrc: + self.log.warning('received error-message from ShmSrc, dropping') + else: + Gst.Bin.do_handle_message(self, msg) + + def event_probe(self, pad, info, ud): + e = info.get_event() + if e.type == Gst.EventType.EOS: + self.log.warning('received EOS-event on event-probe, dropping') + self.switch_to_failstate() + return Gst.PadProbeReturn.DROP + + return Gst.PadProbeReturn.PASS + + + def data_probe(self, pad, info, ud): + self.last_buffer_arrived = time.time() + self.switch_to_goodstate() + return Gst.PadProbeReturn.PASS + + def watchdog(self): + if self.last_buffer_arrived + 0.1 < time.time(): + self.log.warning('watchdog encountered a timeout') + self.switch_to_failstate() + + if self.last_buffer_arrived + 3 < time.time() and round(time.time() % 3) == 0: + self.restart() + + return True + + def restart(self): + self.log.warning('restarting ShmSrc') + self.shmsrc.set_state(Gst.State.NULL) + self.shmsrc.set_state(Gst.State.PLAYING) + + def switch_to_goodstate(self): + if not self.is_in_failstate: + return + + self.log.warning('switching output to goodstate') + self.is_in_failstate = False + self.switch.set_property('active-pad', self.goodpad) + + def switch_to_failstate(self): + if self.is_in_failstate: + return + + self.log.warning('switching output to failstate') + self.is_in_failstate = True + self.switch.set_property('active-pad', self.failpad) diff --git a/voctocore/lib/videomix.py b/voctocore/lib/videomix.py deleted file mode 100644 index f98582a..0000000 --- a/voctocore/lib/videomix.py +++ /dev/null @@ -1,347 +0,0 @@ -import sys, inspect, math -from pprint import pprint -from gi.repository import GLib, Gst -from lib.controlserver import controlServerEntrypoint - -class Videomix: - """mixing, streaming and encoding pipeline constuction and control""" - # size of the monitor-streams - # should be anamorphic PAL, beacuse we encode it to dv and send it to the mixer-gui - monitorSize = (1024, 576) - - previewbins = [] - - def __init__(self): - """initialize video mixing, streaming and encoding pipeline""" - # initialize an empty pipeline - self.pipeline = Gst.Pipeline() - - # create audio and video mixer - mixerbin = self.createMixer() - - # collection of video-sources to connect to the quadmix - quadmixSources = [] - - # create camera sources - for camberabin in self.createDummyCamSources(): - # link camerasource to audiomixer - camberabin.get_by_name('audio_src').link(self.pipeline.get_by_name('liveaudio')) - - # inject a ×2 distributor and link one end to the live-mixer - distributor = self.createDistributor(camberabin.get_by_name('video_src'), camberabin.get_name()) - distributor.get_by_name('a').link(self.pipeline.get_by_name('livevideo')) - - # collect the other end to add it later to the quadmix - quadmixSources.append(distributor.get_by_name('b')) - - # TODO: generate pause & slides with another generator here which only - # yields if the respective files are present and which only have a video-pad - - # add all video-sources to the quadmix-monitor-screen - self.addVideosToQuadmix(quadmixSources, self.pipeline.get_by_name('quadmix')) - - # initialize to known defaults - # TODO: make configurable - self.switchVideo(0) - self.switchAudio(0) - - Gst.debug_bin_to_dot_file(self.pipeline, Gst.DebugGraphDetails.ALL, 'test') - self.pipeline.set_state(Gst.State.PLAYING) - - def createMixer(self): - """create audio and video mixer""" - # create mixer-pipeline from string - mixerbin = Gst.parse_bin_from_description(""" - videomixer name=livevideo ! autovideosink - input-selector name=liveaudio ! autoaudiosink - - videotestsrc pattern="solid-color" foreground-color=0x808080 ! capsfilter name=filter ! videomixer name=quadmix ! autovideosink - """, False) - - # define caps for the videotestsrc which generates the background-color for the quadmix - bgcaps = Gst.Caps.new_empty_simple('video/x-raw') - bgcaps.set_value('width', round(self.monitorSize[0])) - bgcaps.set_value('height', round(self.monitorSize[1])) - mixerbin.get_by_name('filter').set_property('caps', bgcaps) - - # name the bin, add and return it - mixerbin.set_name('mixerbin') - self.pipeline.add(mixerbin) - return mixerbin - - def addVideosToQuadmix(self, videosources, quadmix): - """add all avaiable videosources to the quadmix""" - count = len(videosources) - - # coordinate of the cell where we place the next video - place = [0, 0] - - # number of cells in the quadmix-monitor - grid = [0, 0] - grid[0] = math.ceil(math.sqrt(count)) - grid[1] = math.ceil(count / grid[0]) - - # size of each cell in the quadmix-monitor - cellSize = ( - self.monitorSize[0] / grid[0], - self.monitorSize[1] / grid[1] - ) - - print("showing {} videosources in a {}×{} grid in a {}×{} px window, which gives cells of {}×{} px per videosource".format( - count, grid[0], grid[1], self.monitorSize[0], self.monitorSize[1], cellSize[0], cellSize[1])) - - # iterate over all video-sources - for idx, videosource in enumerate(videosources): - # generate a pipeline for this videosource which - # - scales the video to the request - # - remove n px of the video (n = 5 if the video is highlighted else 0) - # - add a colored border of n px of the video (n = 5 if the video is highlighted else 0) - # - overlay the index of the video as text in the top left corner - # - known & named output - previewbin = Gst.parse_bin_from_description(""" - videoscale name=in ! - capsfilter name=caps ! - videobox name=crop top=0 left=0 bottom=0 right=0 ! - videobox fill=red top=-0 left=-0 bottom=-0 right=-0 name=add ! - textoverlay color=0xFFFFFFFF halignment=left valignment=top xpad=10 ypad=5 font-desc="sans 35" name=text ! - identity name=out - """, False) - - # name the bin and add it - previewbin.set_name('previewbin-{}'.format(idx)) - self.pipeline.add(previewbin) - self.previewbins.append(previewbin) - - # set the overlay-text - previewbin.get_by_name('text').set_property('text', str(idx)) - - # query the video-source caps and extract its size - caps = videosource.get_static_pad('src').query_caps(None) - capsstruct = caps.get_structure(0) - srcSize = ( - capsstruct.get_int('width')[1], - capsstruct.get_int('height')[1], - ) - - # calculate the ideal scale factor and scale the sizes - f = max(srcSize[0] / cellSize[0], srcSize[1] / cellSize[1]) - scaleSize = ( - srcSize[0] / f, - srcSize[1] / f, - ) - - # calculate the top/left coordinate - coord = ( - place[0] * cellSize[0] + (cellSize[0] - scaleSize[0]) / 2, - place[1] * cellSize[1] + (cellSize[1] - scaleSize[1]) / 2, - ) - - print("placing videosource {} of size {}×{} scaled by {} to {}×{} in a cell {}×{} px cell ({}/{}) at position ({}/{})".format( - idx, srcSize[0], srcSize[1], f, scaleSize[0], scaleSize[1], cellSize[0], cellSize[1], place[0], place[1], coord[0], coord[1])) - - # link the videosource to the input of the preview-bin - videosource.link(previewbin.get_by_name('in')) - - # create and set the caps for the preview-scaler - scalecaps = Gst.Caps.new_empty_simple('video/x-raw') - scalecaps.set_value('width', round(scaleSize[0])) - scalecaps.set_value('height', round(scaleSize[1])) - previewbin.get_by_name('caps').set_property('caps', scalecaps) - - # request a pad from the quadmixer and configure x/y position - sinkpad = quadmix.get_request_pad('sink_%u') - sinkpad.set_property('xpos', round(coord[0])) - sinkpad.set_property('ypos', round(coord[1])) - - # link the output of the preview-bin to the mixer - previewbin.get_by_name('out').link(quadmix) - - # increment grid position - place[0] += 1 - if place[0] >= grid[0]: - place[1] += 1 - place[0] = 0 - - def createDistributor(self, videosource, name): - """create a simple ×2 distributor""" - distributor = Gst.parse_bin_from_description(""" - tee name=t - t. ! queue name=a - t. ! queue name=b - """, False) - - # set a name and add to pipeline - distributor.set_name('distributor({0})'.format(name)) - self.pipeline.add(distributor) - - # link input to the tee - videosource.link(distributor.get_by_name('t')) - return distributor - - def createDummyCamSources(self): - """create test-video-sources from files or urls""" - - # TODO make configurable - uris = ('file:///home/peter/122.mp4', 'file:///home/peter/10025.mp4',) - for idx, uri in enumerate(uris): - # create a bin for a simulated camera input - # force the input resolution to 1024x576 because that way the following elements - # in the pipeline cam know the size even if the file is not yet loaded. the quadmixer - # is not resize-capable - camberabin = Gst.parse_bin_from_description(""" - uridecodebin name=input - input. ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1024,height=576,framerate=25/1 ! identity name=video_src - input. ! audioconvert name=audio_src - """, False) - - # set name and uri - camberabin.set_name('dummy-camberabin({0})'.format(uri)) - camberabin.get_by_name('input').set_property('uri', uri) - - # add to pipeline and pass the bin upstream - self.pipeline.add(camberabin) - yield camberabin - - - def createCamSources(self): - """create real-video-sources from the bmd-drivers""" - - # TODO make number of installed cams configurable - for cam in range(2): - # create a bin for camera input - camberabin = Gst.parse_bin_from_description(""" - decklinksrc name=input input=sdi input-mode=1080p25 - input. ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1920,height=1080,framerate=25/1 ! identity name=video_src - input. ! audioconvert name=audio_src - """, False) - - # set name and subdevice - camberabin.set_name('camberabin({0})'.format(cam)) - camberabin.get_by_name('input').set_property('subdevice', cam) - - # add to pipeline and pass the bin upstream - self.pipeline.add(camberabin) - yield camberabin - - def iteratorHelper(self, it): - while True: - result, value = it.next() - if result == Gst.IteratorResult.DONE: - break - - if result != Gst.IteratorResult.OK: - raise IteratorError(result) - - yield value - - def previewBorderHelper(self, previewbin, enabled, color = 'red'): - crop = previewbin.get_by_name('crop') - add = previewbin.get_by_name('add') - add.set_property('fill', color) - for side in ('top', 'left', 'right', 'bottom'): - crop.set_property(side, 5 if enabled else 0) - add.set_property(side, -5 if enabled else 0) - - - ### below are access-methods for the ControlServer - - @controlServerEntrypoint - def numAudioSources(self): - """return number of available audio sources""" - liveaudio = self.pipeline.get_by_name('liveaudio') - return str(len(list(self.iteratorHelper(liveaudio.iterate_sink_pads())))) - - - @controlServerEntrypoint - def switchAudio(self, audiosource): - """switch audio to the selected audio""" - liveaudio = self.pipeline.get_by_name('liveaudio') - pad = liveaudio.get_static_pad('sink_{}'.format(audiosource)) - if pad is None: - return 'unknown audio-source: {}'.format(audiosource) - - liveaudio.set_property('active-pad', pad) - return True - - - @controlServerEntrypoint - def numVideoSources(self): - """return number of available video sources""" - livevideo = self.pipeline.get_by_name('livevideo') - return str(len(list(self.iteratorHelper(livevideo.iterate_sink_pads())))) - - - @controlServerEntrypoint - def switchVideo(self, videosource): - """switch audio to the selected video""" - livevideo = self.pipeline.get_by_name('livevideo') - pad = livevideo.get_static_pad('sink_{}'.format(videosource)) - previewbin = self.pipeline.get_by_name('previewbin-{}'.format(videosource)) - - if pad is None or previewbin is None: - return 'unknown video-source: {}'.format(videosource) - - self.previewBorderHelper(previewbin, True, 'green') - for iterbin in self.previewbins: - if previewbin != iterbin: - self.previewBorderHelper(iterbin, False) - - pad.set_property('alpha', 1) - for iterpad in self.iteratorHelper(livevideo.iterate_sink_pads()): - if pad != iterpad: - #self.previewBorderHelper(iterpad, 0) - iterpad.set_property('alpha', 0) - - - @controlServerEntrypoint - def fadeVideo(self, videosource): - """fade video to the selected video""" - raise NotImplementedError("fade command is not implemented yet") - - - @controlServerEntrypoint - def setPipVideo(self, videosource): - """switch video-source in the PIP to the selected video""" - raise NotImplementedError("pip commands are not implemented yet") - - - @controlServerEntrypoint - def fadePipVideo(self, videosource): - """fade video-source in the PIP to the selected video""" - raise NotImplementedError("pip commands are not implemented yet") - - - class PipPlacements: - """enumeration of possible PIP-Placements""" - TopLeft, TopRight, BottomLeft, BottomRight = range(4) - - - @controlServerEntrypoint - def setPipPlacement(self, placement): - """place PIP in the selected position""" - assert(isinstance(placement, PipPlacements)) - raise NotImplementedError("pip commands are not implemented yet") - - - @controlServerEntrypoint - def setPipStatus(self, enabled): - """show or hide PIP""" - raise NotImplementedError("pip commands are not implemented yet") - - - @controlServerEntrypoint - def fadePipStatus(self, enabled): - """fade PIP in our out""" - raise NotImplementedError("pip commands are not implemented yet") - - - class StreamContents: - """enumeration of possible PIP-Placements""" - Live, Pause, NoStream = range(3) - - - @controlServerEntrypoint - def selectStreamContent(self, content): - """switch the livestream-content between selected mixer output, pause-image or nostream-imag""" - assert(isinstance(content, StreamContents)) - raise NotImplementedError("pause/nostream switching is not implemented yet") diff --git a/voctocore/voctocore.py b/voctocore/voctocore.py index 454a09d..8c15463 100755 --- a/voctocore/voctocore.py +++ b/voctocore/voctocore.py @@ -1,31 +1,84 @@ #!/usr/bin/python3 -import gi, signal +import gi, signal, logging, sys -# import GStreamer and GTK-Helper classes +# import GStreamer and GLib-Helper classes gi.require_version('Gst', '1.0') -from gi.repository import GLib, Gst, Gtk, GObject +from gi.repository import GLib, Gst, GObject + +# check min-version +minGst = (1, 4) +minPy = (3, 0) + +if Gst.version() < minGst: + raise Exception("GStreamer version", Gst.version(), 'is too old, at least', minGst, 'is required') + +if sys.version_info < minPy: + raise Exception("Python version", sys.version_info, 'is too old, at least', minPy, 'is required') + # init GObject before importing local classes GObject.threads_init() Gst.init(None) # import local classes -from lib.videomix import Videomix +from lib.pipeline import Pipeline from lib.controlserver import ControlServer -class Main: +# main class +class Voctocore: + log = logging.getLogger('Voctocore') + def __init__(self): + self.log.debug('creating GObject-MainLoop') + self.mainloop = GObject.MainLoop() + # initialize subsystem - self.videomix = Videomix() - self.controlserver = ControlServer(self.videomix) + self.log.debug('creating Video-Pipeline') + self.pipeline = Pipeline() + + self.log.debug('creating ControlServer') + self.controlserver = ControlServer(self.pipeline) + + def run(self): + self.log.info('running Video-Pipeline') + self.pipeline.run() + + self.log.info('running GObject-MainLoop') + self.mainloop.run() + + def kill(self): + self.log.info('quitting Video-Pipeline') + self.pipeline.quit() + + self.log.info('quitting GObject-MainLoop') + self.mainloop.quit() + + def on_eos(self, bus, msg): + self.log.warning('received EOS-Signal on the Video-Bus from Element %s. This shouldn\'t happen if the program is not terminating right now', msg.src) + self.kill() + + def on_error(self, bus, msg): + err = msg.parse_error() + self.log.error('received Error-Signal on the Video-Bus from Element %s: %s', msg.src, err[1]) + self.kill() + + +# run mainclass +def main(argv): + # configure logging + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)8s %(name)s: %(message)s') -def runmain(): # make killable by ctrl-c + logging.debug('setting SIGINT handler') signal.signal(signal.SIGINT, signal.SIG_DFL) - # start main-class and main-loop - start = Main() - Gtk.main() + # init main-class and main-loop + logging.debug('initializing Voctocore') + voctocore = Voctocore() + + logging.debug('running Voctocore') + voctocore.run() if __name__ == '__main__': - runmain() + main(sys.argv) |