diff -Nru voctomix-1.3+git20200101/configs/blinding.ini voctomix-1.3+git20200102/configs/blinding.ini --- voctomix-1.3+git20200101/configs/blinding.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/blinding.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,22 +0,0 @@ -[mix] -sources = CAM1,CAM2,LAPTOP - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true -; enable live preview so we can see the blinder working -live = true - -[blinder] -; enable live stream blinding -enabled = true -; name a blinding source 'PAUSE' -;videos = PAUSE - -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL diff -Nru voctomix-1.3+git20200101/configs/Decklink_Panasonic_1080i.ini voctomix-1.3+git20200102/configs/Decklink_Panasonic_1080i.ini --- voctomix-1.3+git20200101/configs/Decklink_Panasonic_1080i.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/Decklink_Panasonic_1080i.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,21 +0,0 @@ -[mix] -sources = CAM1,CAM2,LAPTOP - -[source.CAM1] -# grab this source from Decklink card -kind=decklink -# set video scan mode to 'progressive segmented frame' -# tested: Panasonic AVC-CAM (AG-AC160-AEI) at SDI, 1080i/720p (PSF) -scan = psf - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true - -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL diff -Nru voctomix-1.3+git20200101/configs/experimental.ini voctomix-1.3+git20200102/configs/experimental.ini --- voctomix-1.3+git20200101/configs/experimental.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/experimental.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,247 +0,0 @@ -[mix] -#videocaps = video/x-raw,format=I420,width=1920,height=1080,framerate=25/1,pixel-aspect-ratio=1/1,interlace-mode=progressive -audiocaps = audio/x-raw,format=S16LE,channels=8,layout=interleaved,rate=48000 - -; tcp listening ports will be 16000,16001,... -sources = cam1,cam2,cam3,grabber - -[source.cam1] -audio.original = 0+1 - -[source.cam2] -audio.english = 0+1 -audio.french = 2+3 - -[source.grabber] -audio.laptop = 0+1 - -[previews] -enabled = true -live = true -vaapi=h264 -videocaps=video/x-raw,width=1024,height=576,framerate=25/1 - -[blinder] -enabled = true -sources = pause - -[source.blinder] -audio.original = 0+1 -audio.english = 0+1 -audio.french = 0+1 -audio.laptop = 0+1 - -[overlay] -; default selection for overlay image -file = watermark.png|Watermark -; user selection of overlay images -files = transparency.png|Transparency Test,watermark|Watermark,../voc2bg.png|35c3 Background -path = ./data/images/overlays - -; read user selection from schedule.xml file -schedule=schedule.xml -; filter by room -room=HALL 1 -; filter by event ID (good for testing) -;event=3 -; should the user be able to toggle the AUTO-OFF button? -user-auto-off = true -; should the AUTO-OFF button be initially be off? -;auto-off = false -; set fading time when showing or hiding overlay -;blend-time=300 - -[composites] -; fullscreen source A (B is full transparent) -fs.a = * -fs.b = * -fs.alpha-b = 0 -fs.noswap = true - -; fullscreen source A (facing picture-in-picture) -fs-pip.a = * -fs-pip.b = 0.86/0.85 0.0 -fs-pip.alpha-b = 0 -fs-pip.inter = true -fs-pip.mirror = true - -; fullscreen source A (facing side-by-side) -fs-sbs.a = * -fs-sbs.b = 1.0/0.5 0.0 -fs-sbs.alpha-b = 0 -fs-sbs.inter = true - -; fullscreen source A (facing side-by-side-preview) -fs-lec.a = * -fs-lec.b = 1.0 0.0 -fs-lec.alpha-b = 0 -fs-lec.crop-b = 0.31/0 -fs-lec.inter = true -fs-lec.mirror = true - -; picture-in-picture (fullscreen source A with B as small overlay) -pip.a = * -pip.b = 0.73/0.72 0.26 -pip.noswap = true -pip.mirror = true - -; side-by-side (source A at left and B at right side) -sbs.a = 0.008/0.25 0.49 -sbs.b = 0.503/0.25 0.49 - -; side-by-side-preview (source A bigger and B smaller and cropped beside) -lec.a = 0.006/0.01 0.75 -lec.b = 0.60/0.42 0.56 -lec.crop-b = 0.31/0 -lec.mirror = true - -; side-by-side-preview (source A bigger and B smaller and cropped beside) -lec_43.a = -0.125/0.0 1.0 -lec_43.b = 0.60/0.42 0.56 -lec_43.crop-a = 0.125/0 -lec_43.crop-b = 0.31/0 -lec_43.mirror = true - -; fullscreen source B (overlapping A) -fs-b.a = * -fs-b.b = * -fs-b.noswap = true - -; fullscreen source B (facing side-by-side) -fs-b-sbs.a = 0.0/0.5 0.0 -fs-b-sbs.alpha-a = 0.0 -fs-b-sbs.b = * -fs-b-sbs.inter = true - -; fullscreen source B (facing side-by-side-preview) -fs-b-lec.a = 0.0/1.0 0.0 -fs-b-lec.b = * -fs-b-lec.inter = true -fs-b-lec.mirror = true - -; one-opon-the-other (like one-opon-the-other but overlapping) -oao.a = 0.3/0.2 0.4 -oao.alpha-a = 0.5 -oao.b = 0.2/0.3 0.6 -oao.inter = true -oao.noswap = true - -[transitions] -; list of transitions each one can be freely named and is a list of composites -; which will be morphed into an animation. Interpolation will be linear with two -; composites and B-Splines for more. - -; unique name = ms, from / [... /] to -fs-fs = 750, fs / fs-b -fs-pip = 750, fs-pip / pip -fs-sbs = 750, fs-sbs / sbs -fs-b-pip = 750, fs-b / pip -fs-b-sbs = 750, fs-b-sbs / sbs -fs-lec = 750, fs-lec / lec -fs-b-lec = 750, fs-b-lec / lec -fs-lec_43 = 750, fs-lec / lec_43 -fs-b-lec_43 = 750, fs-b-lec / lec_43 -pip-pip = 750, pip / sbs / pip -sbs-sbs = 750, sbs / oao / sbs -_sbs-sbs = 750, ^sbs / ^oao / sbs - -fs-pip_ = 750, |fs-pip / |pip -fs-b-pip_ = 750, fs-b / |pip -fs-lec_ = 750, fs-lec / |lec -fs-lec_43_ = 750, fs-lec / |lec_43 -fs-b-lec_ = 750, fs-b-lec / |lec -fs-b-lec_43_ = 750, fs-b-lec / |lec_43 -pip-pip_ = 750, |pip / sbs / |pip - -; default blending -; unique name = ms, from / [... /] to -def = 750, * / * - - -[toolbar.sources.a] -buttons = cam1,cam2,cam3,grabber - -cam1.key = F1 -cam1.tip = Select source CAM1 on channel A - -cam2.key = F2 -cam2.tip = Select source CAM2 on channel A - -cam3.key = F3 -cam3.tip = Select source CAM3 on channel A - -grabber.key = F4 -grabber.tip = Select source GRABBER on channel A - -[toolbar.sources.b] -buttons = cam1,cam2,cam3,grabber - -cam1.key = 1 -cam1.tip = Select source CAM1 on channel B - -cam2.key = 2 -cam2.tip = Select source CAM2 on channel B - -cam3.key = 3 -cam3.tip = Select source CAM3 on channel B - -grabber.key = 4 -grabber.tip = Select source GRABBER on channel B - -[toolbar.composites] -buttons = fs,sbs,lec - -fs.name = FULL SCREEN -fs.key = F5 -fs.tip = Show channel A on full screen - -sbs.name = SIDE BY SIDE -sbs.key = F6 -sbs.tip = Put channel A beside channel B - -lec.name = LECTURE -lec.key = F7 -lec.tip = Put cropped channel B beside large channel A - -[toolbar.mods] -buttons = mirror,ratio - -mirror.name = MIRROR -mirror.key = F9 -mirror.replace = lec->|lec -mirror.tip = Horizontally mirror composite\n(e.g. when speaker moves to the other side) - -ratio.name = 4:3 -ratio.replace = lec->lec_43 -ratio.key = F10 -ratio.tip = Crop channel A to 4:3 ratio - -[toolbar.mix] -buttons = retake,cut,trans - -retake.name = RETAKE -retake.key = BackSpace -retake.tip = Copy output composite to preview for modification\n(output remains untouched) - -cut.name = CUT -cut.key = Return -cut.tip = Hard cut preview composite to output. -cut.expand = True - -trans.name = TRANS -trans.key = space -trans.tip = Use transition to cut preview composite to output -trans.expand = True - -[toolbar.insert] -auto-off.name = AUTO-OFF -auto-off.key = o -auto-off.tip = automatically turn off insertion before every mix - -update.name = UPDATE -update.key = u -update.tip = Update current event - -insert.name = INSERT -insert.key = i -insert.tip = Show or hide current insertion diff -Nru voctomix-1.3+git20200101/configs/multiple_backgrounds.ini voctomix-1.3+git20200102/configs/multiple_backgrounds.ini --- voctomix-1.3+git20200101/configs/multiple_backgrounds.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/multiple_backgrounds.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,268 +0,0 @@ -[mix] -#videocaps = video/x-raw,format=I420,width=1920,height=1080,framerate=25/1,pixel-aspect-ratio=1/1,interlace-mode=progressive -audiocaps = audio/x-raw,format=S16LE,channels=8,layout=interleaved,rate=48000 - -; tcp listening ports will be 16000,16001,... -sources = cam1,cam2,cam3,grabber -backgrounds = background_sbs, background_lec, background_lecm, background_fs - -[source.cam1] -audio.original = 0+1 - -[source.cam2] -audio.english = 0+1 -audio.french = 2+3 - -[source.grabber] -audio.laptop = 0+1 - -[previews] -enabled = true -live = true -#vaapi=h264 -videocaps=video/x-raw,width=1024,height=576,framerate=25/1 - -[blinder] -enabled = true -sources = pause - -[source.blinder] -audio.original = 0+1 -audio.english = 0+1 -audio.french = 0+1 -audio.laptop = 0+1 - -[source.background_fs] -kind=test -pattern=black -composites=fs - -[source.background_sbs] -kind=img -file=data/images/36c3_sbs.png -composites=sbs - -[source.background_lec] -kind=img -file=data/images/36c3_lec.png -composites=lec,lec_43 - -[source.background_lecm] -kind=img -file=data/images/36c3_lecm.png -composites=|lec,|lec_43 - -[overlay] -; default selection for overlay image -file = watermark.png|Watermark -; user selection of overlay images -files = transparency.png|Transparency Test,watermark|Watermark,../voc2bg.png|35c3 Background -path = ./data/images/overlays - -; read user selection from schedule.xml file -schedule=schedule.xml -; filter by room -room=HALL 1 -; filter by event ID (good for testing) -;event=3 -; should the user be able to toggle the AUTO-OFF button? -user-auto-off = true -; should the AUTO-OFF button be initially be off? -;auto-off = false -; set fading time when showing or hiding overlay -;blend-time=300 - -[composites] -; fullscreen source A (B is full transparent) -fs.a = * -fs.b = * -fs.alpha-b = 0 -fs.noswap = true - -; fullscreen source A (facing picture-in-picture) -fs-pip.a = * -fs-pip.b = 0.86/0.85 0.0 -fs-pip.alpha-b = 0 -fs-pip.inter = true -fs-pip.mirror = true - -; fullscreen source A (facing side-by-side) -fs-sbs.a = * -fs-sbs.b = 1.0/0.5 0.0 -fs-sbs.alpha-b = 0 -fs-sbs.inter = true - -; fullscreen source A (facing side-by-side-preview) -fs-lec.a = * -fs-lec.b = 1.0 0.0 -fs-lec.alpha-b = 0 -fs-lec.crop-b = 0.31/0 -fs-lec.inter = true -fs-lec.mirror = true - -; picture-in-picture (fullscreen source A with B as small overlay) -pip.a = * -pip.b = 0.73/0.72 0.26 -pip.noswap = true -pip.mirror = true - -; side-by-side (source A at left and B at right side) -sbs.a = 0.008/0.25 0.49 -sbs.b = 0.503/0.25 0.49 - -; side-by-side-preview (source A bigger and B smaller and cropped beside) -lec.a = 0.006/0.01 0.75 -lec.b = 0.60/0.42 0.56 -lec.crop-b = 0.31/0 -lec.mirror = true - -; side-by-side-preview (source A bigger and B smaller and cropped beside) -lec_43.a = -0.125/0.0 1.0 -lec_43.b = 0.60/0.42 0.56 -lec_43.crop-a = 0.125/0 -lec_43.crop-b = 0.31/0 -lec_43.mirror = true - -; fullscreen source B (overlapping A) -fs-b.a = * -fs-b.b = * -fs-b.noswap = true - -; fullscreen source B (facing side-by-side) -fs-b-sbs.a = 0.0/0.5 0.0 -fs-b-sbs.alpha-a = 0.0 -fs-b-sbs.b = * -fs-b-sbs.inter = true - -; fullscreen source B (facing side-by-side-preview) -fs-b-lec.a = 0.0/1.0 0.0 -fs-b-lec.b = * -fs-b-lec.inter = true -fs-b-lec.mirror = true - -; one-opon-the-other (like one-opon-the-other but overlapping) -oao.a = 0.3/0.2 0.4 -oao.alpha-a = 0.5 -oao.b = 0.2/0.3 0.6 -oao.inter = true -oao.noswap = true - -[transitions] -; list of transitions each one can be freely named and is a list of composites -; which will be morphed into an animation. Interpolation will be linear with two -; composites and B-Splines for more. - -; unique name = ms, from / [... /] to -fs-fs = 750, fs / fs-b -fs-pip = 750, fs-pip / pip -fs-sbs = 750, fs-sbs / sbs -fs-b-pip = 750, fs-b / pip -fs-b-sbs = 750, fs-b-sbs / sbs -fs-lec = 750, fs-lec / lec -fs-b-lec = 750, fs-b-lec / lec -fs-lec_43 = 750, fs-lec / lec_43 -fs-b-lec_43 = 750, fs-b-lec / lec_43 -pip-pip = 750, pip / sbs / pip -sbs-sbs = 750, sbs / oao / sbs -_sbs-sbs = 750, ^sbs / ^oao / sbs - -fs-pip_ = 750, |fs-pip / |pip -fs-b-pip_ = 750, fs-b / |pip -fs-lec_ = 750, fs-lec / |lec -fs-lec_43_ = 750, fs-lec / |lec_43 -fs-b-lec_ = 750, fs-b-lec / |lec -fs-b-lec_43_ = 750, fs-b-lec / |lec_43 -pip-pip_ = 750, |pip / sbs / |pip - -; default blending -; unique name = ms, from / [... /] to -def = 750, * / * - - -[toolbar.sources.a] -buttons = cam1,cam2,cam3,grabber - -cam1.key = F1 -cam1.tip = Select source CAM1 on channel A - -cam2.key = F2 -cam2.tip = Select source CAM2 on channel A - -cam3.key = F3 -cam3.tip = Select source CAM3 on channel A - -grabber.key = F4 -grabber.tip = Select source GRABBER on channel A - -[toolbar.sources.b] -buttons = cam1,cam2,cam3,grabber - -cam1.key = 1 -cam1.tip = Select source CAM1 on channel B - -cam2.key = 2 -cam2.tip = Select source CAM2 on channel B - -cam3.key = 3 -cam3.tip = Select source CAM3 on channel B - -grabber.key = 4 -grabber.tip = Select source GRABBER on channel B - -[toolbar.composites] -buttons = fs,sbs,lec - -fs.name = FULL SCREEN -fs.key = F5 -fs.tip = Show channel A on full screen - -sbs.name = SIDE BY SIDE -sbs.key = F6 -sbs.tip = Put channel A beside channel B - -lec.name = LECTURE -lec.key = F7 -lec.tip = Put cropped channel B beside large channel A - -[toolbar.mods] -buttons = mirror,ratio - -mirror.name = MIRROR -mirror.key = F9 -mirror.replace = lec->|lec -mirror.tip = Horizontally mirror composite\n(e.g. when speaker moves to the other side) - -ratio.name = 4:3 -ratio.replace = lec->lec_43 -ratio.key = F10 -ratio.tip = Crop channel A to 4:3 ratio - -[toolbar.mix] -buttons = retake,cut,trans - -retake.name = RETAKE -retake.key = BackSpace -retake.tip = Copy output composite to preview for modification\n(output remains untouched) - -cut.name = CUT -cut.key = Return -cut.tip = Hard cut preview composite to output. -cut.expand = True - -trans.name = TRANS -trans.key = space -trans.tip = Use transition to cut preview composite to output -trans.expand = True - -[toolbar.insert] -auto-off.name = AUTO-OFF -auto-off.key = o -auto-off.tip = "automatically turn off insertion before every mix" - -update.name = UPDATE -update.key = u -update.tip = Update current event - -insert.name = INSERT -insert.key = i -insert.tip = Show or hide current insertion diff -Nru voctomix-1.3+git20200101/configs/overlay.ini voctomix-1.3+git20200102/configs/overlay.ini --- voctomix-1.3+git20200101/configs/overlay.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/overlay.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,44 +0,0 @@ -[mix] -sources = CAM1,CAM2,LAPTOP - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true -; enable live preview so we can see the blinder working -live = true - -[overlay] -; path for all image files -path = ./data/images/overlays - -; filter by event ID (good for testing) -;event=3 - -; should the user be able to toggle the AUTO-OFF button? -user-auto-off = true - -; should the AUTO-OFF button be initially be off? -;auto-off = false - -; set fading time when showing or hiding overlay -;blend-time=300 - -; default selection for overlay image -file = watermark.png|Watermark - -; user selection of overlay images -files = transparency.png|Transparency Test,watermark|Watermark,../voc2bg.png|35c3 Background - -; read user selection from schedule.xml file -schedule=schedule.xml - -; filter by room -room=HALL 1 - -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL diff -Nru voctomix-1.3+git20200101/configs/transition-fs-sbs.ini voctomix-1.3+git20200102/configs/transition-fs-sbs.ini --- voctomix-1.3+git20200101/configs/transition-fs-sbs.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/transition-fs-sbs.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,66 +0,0 @@ -[mix] -sources = CAM1,CAM2 - -[source.background] -kind=img -file=data/images/bg.png - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true - -[composites] -; fullscreen source A (B is full transparent) -fs.a = * -fs.b = * -fs.alpha-b = 0 -fs.noswap = true - -; fullscreen source A (facing side-by-side) -fs-sbs.a = * -fs-sbs.b = 1.0/0.5 0.0 -fs-sbs.alpha-b = 0 -fs-sbs.inter = true - -; side-by-side (source A at left and B at right side) -sbs.a = 0.008/0.25 0.49 -sbs.b = 0.503/0.25 0.49 - -; fullscreen source B (overlapping A) -fs-b.a = * -fs-b.b = * -fs-b.noswap = true - -; fullscreen source B (facing side-by-side) -fs-b-sbs.a = 0.0/0.5 0.0 -fs-b-sbs.alpha-a = 0.0 -fs-b-sbs.b = * -fs-b-sbs.inter = true - -; one-opon-the-other (like one-opon-the-other but overlapping) -oao.a = 0.3/0.2 0.4 -oao.alpha-a = 0.5 -oao.b = 0.2/0.3 0.6 -oao.inter = true -oao.noswap = true - -[transitions] -; fade from fullscreen(A) to fullscreen(B) -fs-fs = 750, fs / fs-b -; animate from fullscreen(A) to side-by-side(A,B) -fs-sbs = 750, fs-sbs / sbs -; animate from fullscreen(B) to side-by-side(A,B) -fs-b-sbs = 750, fs-b-sbs / sbs -; animate from side-by-side(A,B) to side-by-side(B,A) -sbs-sbs = 750, sbs / oao / sbs - -[toolbar.composites] -buttons = fs,sbs - -fs.name = FULL SCREEN -fs.key = F5 -fs.tip = Show channel A on full screen - -sbs.name = SIDE BY SIDE -sbs.key = F6 -sbs.tip = Put channel A beside channel B diff -Nru voctomix-1.3+git20200101/configs/transition-sbs.ini voctomix-1.3+git20200102/configs/transition-sbs.ini --- voctomix-1.3+git20200101/configs/transition-sbs.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/transition-sbs.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,26 +0,0 @@ -[mix] -sources = CAM1,CAM2 - -[source.background] -kind=img -file=data/images/bg.png - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true - -[composites] -; side-by-side (source A at left and B at right side) -sbs.a = 0.008/0.25 0.49 -sbs.b = 0.503/0.25 0.49 - -[transitions] -; animate from side-by-side(A,B) to side-by-side(B,A) -sbs-sbs = 750, sbs / sbs - -[toolbar.composites] -buttons = sbs - -sbs.name = SIDE BY SIDE -sbs.key = F6 -sbs.tip = Put channel A beside channel B diff -Nru voctomix-1.3+git20200101/configs/v4l2src.ini voctomix-1.3+git20200102/configs/v4l2src.ini --- voctomix-1.3+git20200101/configs/v4l2src.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/v4l2src.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,24 +0,0 @@ -[mix] -sources = CAM1,CAM2,LAPTOP - -[source.CAM1] -kind=v4l2 -device=/dev/video2 -width=1280 -height=720 -framerate=10/1 -format=YUY2 - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = false -; enable live preview so we can see the blinder working -live = true - -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL diff -Nru voctomix-1.3+git20200101/configs/vaapi_h264.ini voctomix-1.3+git20200102/configs/vaapi_h264.ini --- voctomix-1.3+git20200101/configs/vaapi_h264.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/configs/vaapi_h264.ini 1970-01-01 00:00:00.000000000 +0000 @@ -1,18 +0,0 @@ -[mix] -sources = CAM1,CAM2,LAPTOP - -[previews] -; enable previews so we can see something in VOC2GUI -enabled = true -; enable live preview so we can see the blinder working -live = true -; select H264 by vaapi for preview enconding -vaapi=h264 - -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/36c3_lecm.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/36c3_lecm.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/36c3_lec.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/36c3_lec.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/36c3_sbs.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/36c3_sbs.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/bg.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/bg.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_1_person_1.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_1_person_1.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_1_person_2.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_1_person_2.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_1_person_3.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_1_person_3.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_1_persons.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_1_persons.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_2_person_4.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_2_person_4.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_3_person_1.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_3_person_1.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_3_person_4.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_3_person_4.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/event_3_persons.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/event_3_persons.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/transparency.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/transparency.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/overlays/watermark.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/overlays/watermark.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/data/images/voc2bg.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/data/images/voc2bg.png differ diff -Nru voctomix-1.3+git20200101/debian/changelog voctomix-1.3+git20200102/debian/changelog --- voctomix-1.3+git20200101/debian/changelog 2020-01-02 00:02:36.000000000 +0000 +++ voctomix-1.3+git20200102/debian/changelog 2020-01-03 00:02:30.000000000 +0000 @@ -1,8 +1,8 @@ -voctomix (1.3+git20200101-0+daily20190801~ubuntu18.04.1) bionic; urgency=low +voctomix (1.3+git20200102-0+daily20190801~ubuntu18.04.1) bionic; urgency=low * Auto build. - -- Launchpad Package Builder Thu, 02 Jan 2020 00:02:36 +0000 + -- Launchpad Package Builder Fri, 03 Jan 2020 00:02:30 +0000 voctomix (1.3-4) unstable; urgency=medium diff -Nru voctomix-1.3+git20200101/debian/git-build-recipe.manifest voctomix-1.3+git20200102/debian/git-build-recipe.manifest --- voctomix-1.3+git20200101/debian/git-build-recipe.manifest 2020-01-02 00:02:36.000000000 +0000 +++ voctomix-1.3+git20200102/debian/git-build-recipe.manifest 2020-01-03 00:02:30.000000000 +0000 @@ -1,3 +1,3 @@ -# git-build-recipe format 0.4 deb-version 1.3+git20200101-0+daily20190801 -lp:~voctomix-daily/voctomix/+git/mirror git-commit:b7dc61d2e32a30db69548b905ec9096d92c2711b +# git-build-recipe format 0.4 deb-version 1.3+git20200102-0+daily20190801 +lp:~voctomix-daily/voctomix/+git/mirror git-commit:4c8010c45e6f62ab6bd6fce73447ffc273ccacd6 nest-part packaging lp:~voctomix-daily/voctomix/+git/debian-mirror debian debian git-commit:373c527056f8c40a5c2f7dcd5bdcb265364da667 diff -Nru voctomix-1.3+git20200101/doc/generate_pipeline_png.sh voctomix-1.3+git20200102/doc/generate_pipeline_png.sh --- voctomix-1.3+git20200101/doc/generate_pipeline_png.sh 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/doc/generate_pipeline_png.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,21 +0,0 @@ -#!/bin/sh - - -TMP_DIR=$(mktemp -d) -PNG_DIR=$PWD/doc/pipelines - -echo Generating temporary DOT files into \'$TMP_DIR\' -export GST_DEBUG_DUMP_DOT_DIR=$TMP_DIR - -echo Starting voctocore... -timeout 25s ./voctocore/voctocore.py -v -dg & -echo Waiting 15 seconds... -sleep 15 -echo Starting voctogui... -echo Waiting 5 seconds... -timeout 5s ./voctogui/voctogui.py -vv -d - -cd $TMP_DIR -echo converting DOT to PNG into \'$PNG_DIR\'... -ls -for j in *.dot; do dot -Tpng -o${PNG_DIR}/${j%}.png ${j}; done Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/pipelines/pipeline.dot.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/pipelines/pipeline.dot.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/pipelines/videodisplay.dot.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/pipelines/videodisplay.dot.png differ diff -Nru voctomix-1.3+git20200101/doc/schema.graphml voctomix-1.3+git20200102/doc/schema.graphml --- voctomix-1.3+git20200101/doc/schema.graphml 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/doc/schema.graphml 1970-01-01 00:00:00.000000000 +0000 @@ -1,1173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - voctogui - - - - - - - - - - Folder 2 - - - - - - - - - - - - - - - - Source -View - - - - - - - - - - - UI - - - - - - - - - - - Mix -View - - - - - - - - - - - - - - - - Internet - - - - - - - - - - Folder 3 - - - - - - - - - - - - - - - - Mix -Streaming - - - - - - - - - - - Source -Streaming - - - - - - - - - - - - - - - - voctocore - - - - - - - - - - Folder 4 - - - - - - - - - - - - - - - - Command - - - - - - - - - - - Recording -Compositor - - - - - - - - - - - Live -Compositor - - - - - - - - - - - SB Video - - - - - - - - - - - Video Source - - - - - - - - - - - Mix Live - - - - - - - - - - - Source Live - - - - - - - - - - - Source Preview - - - - - - - - - - - Rescale - - - - - - - - - - - Audio Source - - - - - - - - - - - Mux - - - - - - - - - - - Mux - - - - - - - - - - - SB Audio - - - - - - - - - - - Recording -Audiomixer - - - - - - - - - - - Mix Output - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mix Preview - - - - - - - - - - - Rescale - - - - - - - - - - - Mux - - - - - - - - - - - Command Loop - - - - - - - - - - - Source Output - - - - - - - - - - - Mux - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Live -Audiomixer - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Source -Compositor - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Encoder - - - - - - - - - - Folder 8 - - - - - - - - - - - - - - - - Source -Recording - - - - - - - - - - - Mix -Recording - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - mix -audio - - - - - - - - - - - mix -video - - - - - - - - - - - blank -stream - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-b-pip.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-b-pip.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-b.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-b.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-b-sidebyside.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-b-sidebyside.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-b-sidebysidepreview.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-b-sidebysidepreview.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-fullscreen-both.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-fullscreen-both.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-fullscreen.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-fullscreen.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-pip.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-pip.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-sidebyside.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-sidebyside.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/fullscreen-sidebysidepreview.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/fullscreen-sidebysidepreview.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip-fullscreen-b.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip-fullscreen-b.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip-pip-default.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip-pip-default.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip-pip.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip-pip.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip-pip-key-big.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip-pip-key-big.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip-pip-key.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip-pip-key.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/pip.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/pip.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-b-pip.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-b-pip.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-fullscreen-b.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-fullscreen-b.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-pip.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-pip.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebysidepreview-fullscreen-b.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebysidepreview-fullscreen-b.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebysidepreview.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebysidepreview.png differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebysidepreview-sidebyside-b.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebysidepreview-sidebyside-b.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebysidepreview-sidebyside.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebysidepreview-sidebyside.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebysidepreview-sidebysidepreview.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebysidepreview-sidebysidepreview.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-sidebyside.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-sidebyside.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-sidebysidepreview-b.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-sidebysidepreview-b.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-sidebysidepreview.gif and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-sidebysidepreview.gif differ Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/doc/transitions/images/sidebyside-swapped.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/doc/transitions/images/sidebyside-swapped.png differ diff -Nru voctomix-1.3+git20200101/Dockerfile voctomix-1.3+git20200102/Dockerfile --- voctomix-1.3+git20200101/Dockerfile 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/Dockerfile 2020-01-03 00:02:24.000000000 +0000 @@ -9,7 +9,7 @@ ## core: # docker run -it --rm -v /some/dir:/video # -p 9999:9999 -p 10000:10000 -p 10001:10001 -p 10002:10002 -p 11000:11000 -p 12000:12000 \ -# -p 13000:13000 -p 13001:13001 -p 13002:13002 -p 13100:13100 -p 14000:14000 -p 15000:15000 -p 16000:16000 \ +# -p 13000:13000 -p 13001:13001 -p 13002:13002 -p 14000:14000 -p 15000:15000 -p 16000:16000 \ # -p 17000:17000 -p 17001:17001 -p 17002:17002 -p 18000:18000 --name=voctocore local/voctomix core # ## test sources @@ -44,7 +44,7 @@ RUN mkdir -p /opt/voctomix -EXPOSE 9998 9999 10000 10001 10002 11000 12000 13000 13001 13002 13100 14000 15000 16000 17000 17001 17002 18000 +EXPOSE 9998 9999 10000 10001 10002 11000 12000 13000 13001 13002 14000 15000 16000 17000 17001 17002 18000 VOLUME /video WORKDIR /opt/voctomix diff -Nru voctomix-1.3+git20200101/example-scripts/ffmpeg/record-mixed+slides+8channel-audio-ffmpeg-segmented-timestamps.sh voctomix-1.3+git20200102/example-scripts/ffmpeg/record-mixed+slides+8channel-audio-ffmpeg-segmented-timestamps.sh --- voctomix-1.3+git20200101/example-scripts/ffmpeg/record-mixed+slides+8channel-audio-ffmpeg-segmented-timestamps.sh 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/example-scripts/ffmpeg/record-mixed+slides+8channel-audio-ffmpeg-segmented-timestamps.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,21 +0,0 @@ -#!/bin/sh - -ffmpeg -v verbose -nostats -y -analyzeduration 10000 \ - -thread_queue_size 512 -i tcp://localhost:11000?timeout=3000000 \ - -thread_queue_size 512 -i tcp://localhost:13003?timeout=3000000 \ - -aspect 16:9 \ - -filter_complex \ - "[0]pan=stereo|c0=c0|c1=c1[a]; - [0]pan=stereo|c0=c2|c1=c3[b]; - [0]pan=stereo|c0=c4|c1=c5[c]; - [0]pan=stereo|c0=c6|c1=c7[d]" \ - -map 0:v -c:v:0 mpeg2video -pix_fmt:v:0 yuv420p -qscale:v:0 4 -qmin:v:0 4 -qmax:v:0 4 -keyint_min:v:0 5 -bf:v:0 0 -g:v:0 5 -me_method:v:0 dia \ - -map 1:v -c:v:1 mpeg2video -pix_fmt:v:1 yuv420p -qscale:v:1 4 -qmin:v:1 4 -qmax:v:1 4 -keyint_min:v:1 5 -bf:v:1 0 -g:v:1 5 -me_method:v:1 dia \ - -map "[a]" -c:a s302m \ - -map "[b]" -c:a s302m \ - -map "[c]" -c:a s302m \ - -map "[d]" -c:a s302m \ - -flags +global_header \ - -strict -2 \ - -f segment -segment_time 180 -strftime 1 -segment_format mpegts "segment-%Y-%m-%d_%H-%M-%S-$$.ts" - diff -Nru voctomix-1.3+git20200101/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-mkv-icecast.sh voctomix-1.3+git20200102/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-mkv-icecast.sh --- voctomix-1.3+git20200101/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-mkv-icecast.sh 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-mkv-icecast.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,27 +0,0 @@ -#!/bin/sh -ffmpeg -y -nostdin -hide_banner \ - -thread_queue_size 512 -i tcp://localhost:15000?timeout=3000000 \ - -thread_queue_size 512 -i tcp://localhost:15001?timeout=3000000 \ - -filter_complex \ - "[0:v] hqdn3d [hd]; - [1:v] scale=1024:576, fps=5, hqdn3d [slides]; - [0]pan=stereo|c0=c0|c1=c1[a]; - [0]pan=stereo|c0=c2|c1=c3[b]; - [0]pan=stereo|c0=c4|c1=c5[c]; - [0]pan=stereo|c0=c6|c1=c7[d]" \ - -c:v libx264 -preset:v veryfast -profile:v main -pix_fmt yuv420p -flags +cgop \ - -threads:v 0 -aspect 16:9 \ - -map [hd] -metadata:s:v:0 title="HD" \ - -r:v:0 25 -g:v:0 75 -crf:v:0 21 -maxrate:v:0 4M -bufsize:v:0 18M \ - -map [slides] -metadata:s:v:1 title="Slides" \ - -g:v:1 15 -crf:v:1 25 -maxrate:v:1 100k -bufsize:v:1 750k \ - -c:a aac -b:a 192k -ar 48000 \ - -map "[a]" \ - -map "[b]" \ - -map "[c]" \ - -map "[d]" \ - -f matroska \ - -password password \ - -content_type video/webm \ - icecast://host:8000/mountpoint - diff -Nru voctomix-1.3+git20200101/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-vaapi-mkv-icecast.sh voctomix-1.3+git20200102/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-vaapi-mkv-icecast.sh --- voctomix-1.3+git20200101/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-vaapi-mkv-icecast.sh 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/example-scripts/ffmpeg/stream-mixed+slides+8channel-audio-ffmpeg-vaapi-mkv-icecast.sh 1970-01-01 00:00:00.000000000 +0000 @@ -1,29 +0,0 @@ -#!/bin/sh -ffmpeg -y -nostdin -hide_banner \ - -init_hw_device vaapi=foo:/dev/dri/renderD128 \ - -hwaccel vaapi \ - -hwaccel_output_format vaapi \ - -hwaccel_device foo \ - -thread_queue_size 1024 -i tcp://localhost:15000?timeout=3000000 \ - -thread_queue_size 1024 -i tcp://localhost:15001?timeout=3000000 \ - -filter_hw_device foo \ - -filter_complex \ - "[0:v] hqdn3d, format=nv12,hwupload [hd]; - [1:v] fps=5, hqdn3d, format=nv12,hwupload,scale_vaapi=w=1024:h=576 [slides]; - [0]pan=stereo|c0=c0|c1=c1[a]; - [0]pan=stereo|c0=c2|c1=c3[b]; - [0]pan=stereo|c0=c4|c1=c5[c]; - [0]pan=stereo|c0=c6|c1=c7[d]" \ - -map [hd] -metadata:s:v:0 title="HD" \ - -map [slides] -metadata:s:v:1 title="Slides" \ - -map "[a]" \ - -map "[b]" \ - -map "[c]" \ - -map "[d]" \ - -c:v h264_vaapi -flags +cgop -aspect 16:9 -g:v:1 15 -crf:v:1 25 -maxrate:v:1 100k -bufsize:v:1 750k -r:v:0 25 -g:v:0 75 -crf:v:0 21 -maxrate:v:0 4M -bufsize:v:0 18M\ - -c:a aac -b:a 192k -ar 48000 \ - -f matroska \ - -password password \ - -content_type video/webm \ - icecast://host:8000/mountpoint - diff -Nru voctomix-1.3+git20200101/example-scripts/voctolight/voctolight.py voctomix-1.3+git20200102/example-scripts/voctolight/voctolight.py --- voctomix-1.3+git20200101/example-scripts/voctolight/voctolight.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/example-scripts/voctolight/voctolight.py 2020-01-03 00:02:24.000000000 +0000 @@ -2,7 +2,6 @@ import socket from lib.config import Config import time -import re DO_GPIO = True try: @@ -16,26 +15,14 @@ def __init__(self, source, gpio_port, all_gpios=()): self.source = source + self.state = '' self.gpio_port = gpio_port if DO_GPIO: GPIO.setup(all_gpios, GPIO.OUT) GPIO.output(all_gpios, GPIO.HIGH) - def update(self, composite_func): - restr = "\(|,|\)" - cf = re.split(restr, composite_func) - if cf[0] == 'fs': - if cf[1] == self.source: - self.tally_on() - else: - self.tally_off() - else: - if self.source in cf[1:]: - self.tally_on() - else: - self.tally_off() - - + def set_state(self, state): + self.state = state def tally_on(self): if DO_GPIO: @@ -47,6 +34,18 @@ GPIO.output(self.gpio_port, GPIO.HIGH) print('Tally off') + def video_change(self, source_a, source_b): + if self.state == 'fullscreen': + if source_a == self.source: + self.tally_on() + else: + self.tally_off() + else: + if self.source in (source_a, source_b): + self.tally_on() + else: + self.tally_off() + def start_connection(tally_handler): @@ -58,7 +57,9 @@ sock.settimeout(None) messages = [] - sock.send(b'get_composite\n') + sock.send(b'get_composite_mode\n') + sock.send(b'get_video\n') + sock.send(b'get_stream_status\n') while True: if len(messages) == 0: message = sock.recv(2048) @@ -74,9 +75,11 @@ if len(messages) != 0: messages = messages[1:] try: - if message[0] == 'composite': - composite_func = message[1] - tally_handler.update(composite_func) + if message[0] == 'composite_mode': + tally_handler.set_state(message[1]) + elif message[0] == 'video_status': + source_a, source_b = message[1], message[2] + tally_handler.video_change(source_a, source_b) except IndexError: pass diff -Nru voctomix-1.3+git20200101/README.md voctomix-1.3+git20200102/README.md --- voctomix-1.3+git20200101/README.md 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/README.md 2020-01-03 00:02:24.000000000 +0000 @@ -1,4 +1,9 @@ # Voctomix [![Build Status](https://travis-ci.org/voc/voctomix.svg?branch=master)](https://travis-ci.org/voc/voctomix) + +**Hint**: Also see upcoming version 2 called *VOC2MIX* which can be found at [branch `voctomix2`](https://github.com/voc/voctomix/tree/voctomix2). + +--- + The [C3VOC](https://c3voc.de/) creates lecture recordings from German hacker/tech conferences. In the past we have used [dvswitch](http://dvswitch.alioth.debian.org/wiki/) very successfully but it has some serious limitations. Therefore we started looking for a replacement in 2014. We tested [snowmix](http://sourceforge.net/projects/snowmix/) and [gst-switch](https://github.com/timvideos/gst-switch) and while both did some things we wanted right, we realised that no existing tool would be able to fulfil all our wishes. Furthermore both are a nightmare to extend. So we decided to build our own implementation of a Live-Video-Mixer. ## Subprojects @@ -13,13 +18,10 @@ Install the required dependencies: ```` # Requirements -apt-get install gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly gstreamer1.0-tools libgstreamer1.0-0 python3 python3-gi gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 python3-sdnotify python3-scipy +apt-get install gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly gstreamer1.0-tools libgstreamer1.0-0 python3 python3-gi gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 # Optional for the Example-Scripts apt-get install python3-pyinotify gstreamer1.0-libav rlwrap fbset ffmpeg netcat gstreamer1.0-vaapi - -# Tooling for Development -apt-get install pycodestyle python-autopep8 ```` diff -Nru voctomix-1.3+git20200101/README-TRANSITIONS.md voctomix-1.3+git20200102/README-TRANSITIONS.md --- voctomix-1.3+git20200101/README-TRANSITIONS.md 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/README-TRANSITIONS.md 1970-01-01 00:00:00.000000000 +0000 @@ -1,814 +0,0 @@ -# Vocotomix Transitions - -## TOC - - -- [TOC](#toc) -- [Purpose](#purpose) -- [Use Cases](#use-cases) - - [Composites](#composites) - - [Transitions](#transitions) -- [Operations](#operations) - - [Composite Operations](#composite-operations) - - [Transition Operations](#transition-operations) -- [Interfaces](#interfaces) - - [Composites](#composites) - - [Transitions](#transitions) -- [Entities](#entities) - - [Transition](#transition) - - [Composite](#composite) - - [Frame](#frame) -- [Configuration](#configuration) - - [Configure Composites](#configure-composites) - - [Configure Transitions](#configure-transitions) -- [Using Transitions](#using-transitions) -- [Transition Tester](#transition-tester) - - [Example Usage](#example-usage) - - [Using verbose mode](#using-verbose-mode) - - [Code](#code) -- [TODO](#todo) - - [Future Development](#future-development) - - - -## Purpose - -The purpose of _voctomix_ __transitions__ is to implement an easy way to semi-automatize fading from one _video mixing scenario_ to another. We call those scenarios __composites__. A composite in _voctomix_ traditionally consists of up to two mixed video sources __A__ and __B__ whose representation parameters we call __frames__. - -So far _voctomix_ was capable of using the following preset composites: - -- single source __*c*(A)__ - - single source full screen (fs) -- two sources __*c*(A,B)__ - - side-by-side (sbs) - - picture-in-picture (pip) - - side-by-side-preview (sbsp) - -Until transitions existed in _voctomix_, switching between any of these compositing scenarios was made rapidly from one frame to the next. The idea of transitions is to fade between composites by doing an animation and/or alpha (transparency) blending. With _voctomix_ __transitions__ we like to produce the most expected result for every possible scenario and give the user also the ability to create new composites and adjust or improve existing ones or even invent new transitions. - -## Use Cases - -### Composites - -A __composite__ is a mix of multiple source images sources into a single destination image. -The _voctomix_ project was previously using four fixed composites called _fullscreen_, _picture-in-picture (pip)_, _side-by-side_ and _side-by-sidepreview_. We can divide these transitions into the following categories. -_Voctomix_ also uses a background source image which will not be discussed here because it is just irrelevant for handling transitions. - -The images below show source __A__ in red and source __B__ in blue. - -#### c(A), c(B) - -![fullscreen A composite](doc/transitions/images/fullscreen.png) -![fullscreen B composite](doc/transitions/images/fullscreen-b.png) - -We name these kind of composites __c(A)__ or __c(B)__. - -#### c(A,B) - -![pip composite](doc/transitions/images/pip.png) -![sidebyside composite](doc/transitions/images/sidebyside.png) -![sidebysidepreview composite](doc/transitions/images/sidebysidepreview.png) - -We name these kind of composites __c(A,B)__. - -We later also differ between overlapping and __non-overlapping composites__. -_pip_ for example is an __overlapping composite__ because the image of source B overlaps that of source A. - -### Transitions - -Generally we can differ between the following transition cases. - -#### *c*(A) ↔ *c*(B) - -![fullscreen-fullscreen transition](doc/transitions/images/fullscreen-fullscreen.gif) -![another fullscreen-fullscreen transition](doc/transitions/images/fullscreen-fullscreen-both.gif) - -First case is to switch from one full screen source to another by switching A ↔ B. The most common method here is to blend transparency of both sources from one to the other. - -#### *c*(A) ↔ *c*(A,B) - -![fullscreen-pip transition](doc/transitions/images/fullscreen-pip.gif) -![fullscreen-sidebyside transition](doc/transitions/images/fullscreen-sidebyside.gif) -![fullscreen-sidebysidepreview transition](doc/transitions/images/fullscreen-sidebysidepreview.gif) - -Switch from full screen to a composite of both sources can be done by blending the alpha channel of the added source from transparent to opaque or by an animation of the incoming source or both. - -#### *c*(B) → *c*(A,B) - -![fullscreen-b-pip transition](doc/transitions/images/fullscreen-b-pip.gif) -![fullscreen-b-sidebyside transition](doc/transitions/images/fullscreen-b-sidebyside.gif) -![fullscreen-b-sidebysidepreview transition](doc/transitions/images/fullscreen-b-sidebysidepreview.gif) - -#### *c*(A,B) ↔ *c*(B,A) - -![pip-pip transition](doc/transitions/images/pip-pip.gif) -![sidebyside-sidebyside transition](doc/transitions/images/sidebyside-sidebyside.gif) -![sidebysidepreview-sidebysidepreview transition](doc/transitions/images/sidebysidepreview-sidebysidepreview.gif) - -To switch between A and B within a composite an animation is preferable. In some composites like _picture-in-picture_ (see in the middle) the second source (B) is overlapping the first one (A) and so the *z-order* (order in which the frames have to be drawn) has to be flipped within a transition to get a proper effect. - -To guarantee that this is possible transitions can be improved by inserting so-called __intermediate composites__ which add __key frames__ for both sources in which they do not overlap and so bring a chance to do the z-order swap. -_voctomix_ __transitions__ is then using *B-Splines* to interpolate a smooth motion between __*c*(A,B)__ ↔ __*t'*(A,B)__ ↔ __*c*(B,A)__. You even can use multiple intermediate composites within the same transition, if you like. - -#### *c1*(A,B) ↔ *c2*(A,B) - -![sidebyside-sidebysidepreview transition](doc/transitions/images/sidebyside-sidebysidepreview.gif) -![sidebysidepreview-sidebyside transition](doc/transitions/images/sidebysidepreview-sidebyside.gif) -![sidebyside-pip transition](doc/transitions/images/sidebyside-pip.gif) - -Switching the composite while leaving the sources A and B untouched is similar to the previous case __*c*(A,B)__ ↔ __*c*(B,A)__ except that there is usually no need to have intermediate composites to switch the z-order because A and B remain unswapped. - -#### *c1*(A,B) ↔ *c2*(B,A) - -![sidebyside-sidebysidepreview-b transition](doc/transitions/images/sidebyside-sidebysidepreview-b.gif) -![sidebysidepreview-sidebyside-b transition](doc/transitions/images/sidebysidepreview-sidebyside-b.gif) -![pip-sidebyside-b transition](doc/transitions/images/sidebyside-b-pip.gif) - -#### Three-Sources Transitions - -Switching a source of one frame within a composite to another (external) source leads to a *three-sources transition* because three sources have to be mixed together simultaneously. -To avoid the complexity of transitions of such a higher grade we like to stay with the real use cases which occur when working with *voctomix*. -We exclude all three sources transitions except those of one type. - -##### *c*(A1) ↔ *c*(A2) -When you start with a single source composite and you switch that one input source to another one which is not the hidden source B, you just have to switch source B to A2 first and then do a two source transition *c*(A1) ↔ *c*(B) which is then equivalent to *c*(A1) ↔ *c*(A2). - -##### *c*(A) ↔ *c*(A,B2) - -We easily can cover this one with the same switching maneuver as in the previous case. - -##### *c*(A1,B) ↔ *c*(A2,B) - -The remaining one is different: -If you want to switch one of the sources within a two source composite to a new (external) source you need a real three sources transition to animate that case. - -Again we like to restrict that issue to the real use case where we use just one type of three source transition which is based on a two source transitions and just mixes one source to another external by transparency fading. - -This case is currently out of scope for transitions and is solved within the mixer. - -## Operations - -To minimize the amount of composites and transitions which must be configured, missing composites and transitions shall be auto-generated. -The rules to do that are listed below. - -Mostly these operations are inserted automatically. -They are described here for better understanding of how _voctomix_ **transitions** work and to understand the debugging output of the resulting code. - -### Composite Operations - -#### Equivalence - -A frame of empty size is invisible like one which is fully transparent but has an extent. -Also a composite of two frames where one is opaque and overlapping the other completely includes one invisible frame. -So those kind of composites may be treated as equivalent if one frame differs in both composites by some properties but is overall still invisible in both. - -So when _voctomix_-**transitions** is looking for a matching composite it uses this rule which shortens the list of necessary manual transition definitions. - -#### Swap - -A composite of source A and B _c(A,B)_ can be swapped by simply swapping A and B like in this example: - -![sidebyside composite](doc/transitions/images/sidebyside.png) -![sidebyside composite](doc/transitions/images/sidebyside-swapped.png) - -We mark that swap operation with a `^` sign. -Put into a formula we can write this as - -
-^*c*(A,B) = *c*(A,B) -
- -#### Gamma - -The **gamma operation** is used for that one remaining *three sources transition* case when you switch one of the sources to another input within a two source composite. - -Taking a two source composite *c*(A,B) and a third source C this operation has a transition *T* as result. - -We describe that with the upper case Greek letter **Γ** as: - -
-*T* = Γ(*c*,C) = Γ(*c*(A,B),C) = *c*(A,B) → *c*(A,C) -
-or -
-*T* = Γ(C,*c*) = Γ(C,*c*(A,B)) = *c*(A,B) → *c*(C,B) -
- -Where *T* is the resulting transition, *c* is the current composite and *A*,*B* and *C* the related sources. - -Because *voctomix* **transitions** are focusing on two sources composites and translations we will integrate that case in separately into the mixer. - -### Transition Operations - -#### Reverse - -![sidebyside composite](doc/transitions/images/sidebysidepreview-sidebyside.gif) -![sidebyside composite](doc/transitions/images/sidebyside-sidebysidepreview.gif) - -A transition *T* from composite *c1* to composite *c2* written as... - -*T* = *c1* → *c2* - -...can be reversed. - -We mark that reverse operation with an exponent of `-1`: - -*T*-1 = (*c1*(A,B) → *c2*(A,B))-1 = *c2*(A,B) → *c1*(A,B) - -Or shorter: - -*T*-1 = (*c1* → *c2*)-1 = *c2* → *c1* - -#### Phi Φ() - -This operation is needed to handle some transitions between _overlay composites_. -It works different because it does not change a transition but it's processing. -We call that operation _Φ()_. - -Overlay composites have a so-called _z-order_ which defines that B is drawn above A. -If you take an overlay composite like _picture-in-picture_, generating an animation for swapping both sources must include a switch of this z-order. - -This is done with the _Φ()_ operation which finds the first composite within a transition where source B do not even partially cover the image of source A. -To profit by this operation one must specialize this transition an put a non-overlaying composite between the target composites. - -So to get a proper picture-in-picture &arr; picture-in-picture transition we can put a side-by-side composite between: - -
-Φ(*pip* ↔ *sbs* ↔ *pip*) -
- -The result with a side-by-side composite in the middle looks like: - -![pip-pip transition](doc/transitions/images/pip-pip-key.gif) - -On the left you can see the added side-by-side composite as rectangles and you can see that A and B are swapping somewhere within the animation. - -Without side-by-side in the middle it would look like: - -![pip-pip transition](doc/transitions/images/pip-pip-default.gif) - -...which is worse than a hard cut. - -## Interfaces - -To use the following code you first need to import some stuff. - -```python -from transitions import Transitions, Composites -``` - -Maybe importing `L`, `T`, `R` and `B` is a good idea too because it can be helpful when accessing the coordinates of the resulting animation frames. -`X` and `Y` can be used to access width and height in `size`. - - -### Composites - -`Composites` (plural) is a python class of the preferred interface to _voctomix_ __composites__ and includes the following function: - -#### Composites.configure() -Reads a configuration and returns all included composites. -Take that return value and give it to `Transitions.configure()` to load the transitions configuration. -You may also use the included composites to set up your compositor's switching capabilities - for example in the user interface. -```python -def configure(cfg, size): -``` -Additionally you have to give `size` which must be a list including _width_ and _height_ in pixels of both source frames. -It is an external parameter and should be given by your compositor configuration. -`size` will be used to calculate coordinates from any proportional floating point values inside the configuration and for calculating source related coordinates of any cropping. - -The return value is a dictionary of `string` → `Composite`. - -`configure()` may throw an `RuntimeError` exception when parsing the syntax causes problems. - -In *future development* this could also take different `size` values for each source too. - -### Transitions - -`Transitions` holds a transition table from all configured target composites to each other. - -#### Transitions.configure() -Reads a configuration and returns all included transitions. -Take that return value and give it to `find()` to fetch a specific transition. -```python -def configure(cfg, composites, fps=25): -``` -Generates all transitions configured by the list of named configuration values in dictionary `cfg` (`string` → `string`) by using the given `composites` and `fps` (frames per second) and return them in a dictionary of `string` → `Transition`. - -`configure()` may throw an `RuntimeError` exception when parsing the syntax causes problems. - -#### Transitions.add() -This method is mainly used internally by `config()` but you also can use it to add transitions manually to the transition table. -```python -def add(self, transition, frames, overwrite=False): -``` -`transition` is added to at all position in the table where it matches. -`frames` is the number of frames the (re-)calculated transition shall last. -When `overwrite` is `False` existing transitions will not be overwritten. - -#### Transitions.find() -Fetch a transition whose beginning and ending is matching the given composites. -```python -def find(begin, end): -``` -Searches in the given dictionary `transitions` for a transition that fades `begin` to `end`. -In a second step also checks if reversed versions transitions match. -If a transition was found a tuple of it's name and the transition will be returned - otherwise `None`. - -#### Transitions.travel() -Returns a list of pairs of composites along all possible transitions between all given `composites` by walking the tree of all combinations recursively. -```python -def travel(composites, previous=None): -``` -Parameter `previous` shall always be the default `None` and must only be used internally for recursion. -This method is just a tool to walk all possible transitions in one animation and so concatenate existing transitions. - -Currently it is only used within the _Transition Tester_ to generate test output but could be also subject of *future development* to generate more complex animations by concatenation. - -## Entities - -### Transition - -A transition consists of a list of composites. -These composites can be either: -- two or more in a list of __key composites__ to generate an animation for -- or a list of composites which describe an already generated animation and so a ready-to-go transition. - -#### Transition.frames() -Returns the number of composites stored in this transition. -```python -def frames(self): -``` -The return value can be either the _number of key frames_ or the _number frames of an animation_ depending on if this transition instance is meant to be used as a parameter to calculate an animation or as return value of that calculation. - -#### Transition.A/B() -Returns one frame of the sources A or B which shall be realized within your compositor. -```python -def A(self, n): -def B(self, n): -``` -Precisely returns `Frame` number `n` of source A or B of the `Transtition`. - -#### Transition.flip() -Return the index of the frame preferred to flip both sources (and the scenario) to get a proper z-order behavior. -```python -def flip(self): -``` -Using this information is strongly recommended to get smooth results, when using transitions of type *c*(A,B) ↔ *c*(B,A). - -#### Transition.begin/end() -Returns the begin or end composite of that transition. -```python - def begin(self): - def end(self): -``` - -#### Transition.name() -Returns the name of that transition. -```python - def name(self): -``` - -### Composite - -A `Composite` instance includes two frames for source A and B and includes no functions you need to know about to use _voctomix_ __transitions__. - -#### Composite.inter -Marks intermediate composites if `True`. -``` -self.inter = False -``` -Intermediate composites can be marked by this flag to avoid their appearance in an UI selection for example. - -### Frame - -A __Frame__ includes all the information necessary to set up a single source in your compositor: - -#### Frame.rect -Returns the dimensions in pixels of this frame. -```python -self.rect = [0, 0, 0, 0] -``` -The value is a list of coordinates of the _left_, _top_, _right_ and _bottom_ of the frame. -Use imported constants `L`, `T`, `R` and `B` to access these list elements. -The default is an empty rectangle in the upper-left corner. - -#### Frame.alpha -The transparency value of this frame. -```python -self.alpha = 255 -``` -A value between `0` and `255` where `0` means invisible, `255` means opaque and values between describe the corresponding semi-transparency. - - -#### Frame.crop -The source's cropping values which may change within a transition animation. -```python -self.crop = [0, 0, 0, 0] -``` -The values are the _left_, _top_, _right_ and _bottom_ cropping meant as distance from the frame's originally borders. -Because cropping is a property of the video source these values will be related to the source size given to `Composites.configure()`. - -#### Frame.key -This member is `True` if this is a key frame loaded from the configuration or `False` if it was generated by interpolation. -```python -self.key = False -``` - -#### Frame.cropped() -Returns the cropped rectangle of the frame related to the current size of the frame. -```python -def cropped(self): -``` -Use this to get the resulting frame's extent which will be visible in the image mixed by your compositor. - - -## Configuration - -Configuration is done with an INI file like all the other _voctomix_ configuration. - -Additionally we like to leave the configuration as easy and compatible as possible to former configurations. -Because we also like to increase the flexibility, any unification of the composites does indeed lead to a quite different configuration format. -To keep migration easy the basic options and values are mostly just reordered or renamed. - -### Configure Composites - -List of configurations of custom named composites for mixing video sources A and B. - -Attribute | Format | Default | Description -----------------|--------|-------------|------------------------------------- -_name_`.a` | RECT | no output | position and size of frame A -_name_`.b` | RECT | no output | position and size of frame B -_name_`.crop-a` | CROP | no cropping | cropping borders of frame A -_name_`.crop-b` | CROP | no cropping | cropping borders of frame B -_name_`.alpha-a`| ALPHA | opaque | opacity of frame A -_name_`.alpha-b`| ALPHA | opaque | opacity of frame B -_name_`.noswap` | BOOL | swap | prevents to target swapped composite - -So a default frame without any attributes is invisible by his zero extent. - -#### _name_ -All attributes begin with the composite's name followed by a dot `.` and the attribute's name. -A composite can be freely named but _name_ must be unique. - -#### Absolute and Proportional Coordinates - -In __RECT__ and __CROP__ you may decide if you like to use _absolute pixel coordinates_ or _proportional floating point values_. -Using proportional values is often an advantage because you can easily change the full screen size once and all other pixel values will be automatically calculated with that size. -This enables you to use the same composites configuration with different resolutions but similar aspect ratio. - -#### RECT -Rectangular coordinates are given in different formats like: `X/Y WxH`, `POS WxH`, `X/Y SIZE`, `POS SIZE` or `*`. - -Whereat `X`,`Y`,`W`,`H` can be mixed integer pixel coordinates or float proportions. -`POS` and `SIZE` need to be float proportions. -`*` stands for full screen and inserts the `size` which was once given to `Composites.configure()`. - -##### Examples -```ini -c.a = 10/10 100x100 ; source A pixel dimensions with format 'X/Y WxH' -``` -```ini -c.a = 0.4 0.2 ; source A float proportions with format 'XY WH' -``` -```ini -c.a = 0.1/10 0.9 ; stupid mixup with format 'X/Y WH' -c.b = * ; source B becomes full screen -``` - -#### CROP -Cropping borders which are given by either `L/T/R/B`, `LR/TB` or `LRTB` - -`L`,`T`,`R`,`B`, `LR`,`TB` and `LRTB` can be mixed integer absolute coordinates or float -proportions. - -##### Examples -```ini -c.crop-a = 0/100/0/100 ; source A pixel cropping borders with format 'L/T/R/B' -``` -```ini -c.crop-a = 0.0/0.2 ; source A float proportional borders with format 'LR/TB' -``` -```ini -c.crop-b = 0.1 ; source B 10% from each border in format 'LRTB' -``` - -#### ALPHA - -Integer value in the range between `0` (invisible) and `255` (opaque) or float value between `0.0` (invisible) and `1.0` (opaque) or `*` (also opaque). - -#### BOOL - -Any value is true but false if empty. - -##### Examples -```ini -c.alpha-a = * ; opaque source A using '*' -c.alpha-b = 0.5 ; 50% semitransparent source B as float -``` - -### Configure Transitions - -The configuration of a transition is more easy. -List all transitions in an ini section like `[transitions]`. -Each one can be freely named and describes a timespan and a list of composites which will be processed into an animation. Interpolation will be linear with two composites and B-Splines for more. - -```ini -my_transition = 1000, pip / sidebyside -``` -This generates a linear transition from composite `pip` to composite `sidebyside` lasting one second (`1000` milliseconds). -```ini -my_transition = 1500, pip / sidebyside / pip -``` -This generates a B-Spline transition from composite `pip` to composite `sidebyside` to composite `pip` (with A and B automatically swapped) with a duration of one and a half seconds (`1500` milliseconds). - -## Using Transitions - -```python - from transitions import Composites, Transitions, L, T, R, B - from configparser import SafeConfigParser - from out_of_scope import update_my_compositor - - # set frame size - size = [1920, 1080] - # set frames per second - fps = 25 - # load INI files - config = SafeConfigParser() - config.read(filename) - # read composites config section - composites = Composites.configure(config.itemc('composites'), size) - # read transitions config section - transitions = Transitions.configure(config.itemc('transitions'), composites, fps) - - # search for a transitions that does a fade between fullscreen and sidebyside composites - t_name, t = Transitions.find(composites["fullscreen"], composites["sidebyside"], transitions) - # stupid loop through all frames of the animation - for i in range(t.framec()): - # access current frame in animation to update compositing scenario - update_my_compositor( t.A(i), t.B(i) ) -``` - -## Transition Tester - -The transitions tester lists information about what composites and transitions are defined in a configuration called `composite.ini` and generates PNG files or animated GIF for each listed transition. -You may also select additional drawing of cropping, key frames or a title by command line option or take a further look into the calculations by using verbose mode. - -```raw -▶ python3 testtransition.py -h -usage: testtransition.py [-h] [-m] [-l] [-g] [-t] [-k] [-c] [-C] [-r] [-n] - [-P] [-L] [-G] [-v] - [composite [composite ...]] - -transition - tool to generate voctomix transition animations for testing - -positional arguments: - composite list of composites to generate transitions between (use all - available if not given) - -optional arguments: - -h, --help show this help message and exit - -m, --map print transition table - -l, --list list available composites - -g, --generate generate animation - -t, --title draw composite names and frame count - -k, --keys draw key frames - -c, --corners draw calculated interpolation corners - -C, --cross draw image cross through center - -r, --crop draw image cropping border - -n, --number when using -g: use consecutively numbers as file names - -P, --nopng when using -g: do not write PNG files (forces -G) - -L, --leave when using -g: do not delete temporary PNG files - -G, --nogif when using -g: do not generate animated GIFS - -v, --verbose also print WARNING (-v), INFO (-vv) and DEBUG (-vvv) - messages - -``` - -### Example Usage - -``` -▶ python3 testtransition.py -lvgCctk pip -1 targetable composite(s): - pip -1 intermediate composite(s): - fullscreen-pip -saving transition animation file 'pip-pip.gif' (pip-pip, 37 frames)... -1 transitions available: - pip-pip -``` - -This call generates the following animated GIF: - -![pip-pip transition with keyframes](doc/transitions/images/pip-pip-key-big.gif) - -You can see the key frames of `pip` `A.0`=`B.2` and `B.0`=`A.2` of the start and end composite. In the vertical center you can see the key frames `A.1` and `B.1` given by `sidebyside` to produce a moment of non-overlapping. At the first time when the blue frame `B` is not overlapping the red one `A` the flipping point is reached and sources `A`/`B` can be flipped without side effects. - -The following configuration file was used to generate that animation: - -```ini -[output] -; full screen size in pixels -size = 960x540 -; frames per second to render -fps = 25 - -[composites] -; Frame A full screen -pip.a = * -; frame B lower-right corner with 16% size -pip.b = 0.83/0.82 0.16 - -; left-middle nearly half size -sidebyside.a = 0.008/0.25 0.49 -; right-middle nearly half size -sidebyside.b = 0.503/0.25 0.49 - -[transitions] -; transition from pip to pip (swapped) over sidebyside within 1.5 seconds -pip-pip = 1500, pip / sidebyside / pip -``` - -### Using verbose mode - -In verbose mode you can see more information about how a transition will be found and what it's data looks like: - -```raw -▶ python3 testtransition.py -vvv pip -reading composites from configuration... -read 2 composites: - sbs - pip -reading transitions from configuration... -adding transition pip-pip = pip -> pip - pip-pip = pip -> pip: - No. Key A( L, T R, B alpha LCRP,TCRP,RCRP,BCRP XZOM,YZOM) B( L, T R, B alpha LCRP,TCRP,RCRP,BCRP XZOM,YZOM) Name - 0 * A( 0, 0 240, 135 255 0, 0, 0, 0 0.00,0.00) B( 199, 110 237, 131 255 0, 0, 0, 0 0.00,0.00) pip - 1 * A( 1, 33 118, 99 255 0, 0, 0, 0 0.00,0.00) B( 120, 33 237, 99 255 0, 0, 0, 0 0.00,0.00) sbs - 2 * A( 0, 0 240, 135 255 0, 0, 0, 0 0.00,0.00) B( 199, 110 237, 131 255 0, 0, 0, 0 0.00,0.00) pip -calculating transition pip-pip = pip/sbs/pip -read 1 transition(s): - pip-pip -using 1 target composite(s): - pip -generated sequence (2 items): - pip - pip -request transition (1/1): pip → pip -transition found: Φ(pip-pip) - Φ(pip-pip) = pip -> pip: - No. Key A( L, T R, B alpha LCRP,TCRP,RCRP,BCRP XZOM,YZOM) B( L, T R, B alpha LCRP,TCRP,RCRP,BCRP XZOM,YZOM) Name - 0 * A( 0, 0 240, 135 255 0, 0, 0, 0 0.00,0.00) B( 199, 110 237, 131 255 0, 0, 0, 0 0.00,0.00) pip - 1 A( 0, 0 239, 135 255 0, 0, 0, 0 0.00,0.00) B( 199, 110 237, 131 255 0, 0, 0, 0 0.00,0.00) ... - 2 A( 0, 0 238, 134 255 0, 0, 0, 0 0.00,0.00) B( 198, 108 238, 130 255 0, 0, 0, 0 0.00,0.00) ... - 3 A( 4, 0 234, 130 255 0, 0, 0, 0 0.00,0.00) B( 196, 106 240, 131 255 0, 0, 0, 0 0.00,0.00) ... - 4 A( 9, 0 228, 123 255 0, 0, 0, 0 0.00,0.00) B( 193, 101 245, 130 255 0, 0, 0, 0 0.00,0.00) ... - 5 A( 15, 1 219, 116 255 0, 0, 0, 0 0.00,0.00) B( 188, 95 249, 129 255 0, 0, 0, 0 0.00,0.00) ... - 6 A( 21, 2 208, 107 255 0, 0, 0, 0 0.00,0.00) B( 182, 87 254, 127 255 0, 0, 0, 0 0.00,0.00) ... - 7 A( 24, 4 194, 100 255 0, 0, 0, 0 0.00,0.00) B( 175, 79 258, 126 255 0, 0, 0, 0 0.00,0.00) ... - 8 A( 27, 6 180, 92 255 0, 0, 0, 0 0.00,0.00) B( 167, 70 261, 123 255 0, 0, 0, 0 0.00,0.00) ... - 9 A( 26, 9 164, 87 255 0, 0, 0, 0 0.00,0.00) B( 157, 60 260, 118 255 0, 0, 0, 0 0.00,0.00) ... - 10 A( 20, 13 147, 84 255 0, 0, 0, 0 0.00,0.00) B( 145, 50 256, 112 255 0, 0, 0, 0 0.00,0.00) ... - ----------------------------------------------------------- FLIP SOURCES ------------------------------------------------------------ - 11 B( 11, 20 130, 87 255 0, 0, 0, 0 0.00,0.00) A( 133, 41 248, 106 255 0, 0, 0, 0 0.00,0.00) ... - 12 * B( 1, 33 118, 99 255 0, 0, 0, 0 0.00,0.00) A( 120, 33 237, 99 255 0, 0, 0, 0 0.00,0.00) sbs - 13 B( 1, 32 118, 98 255 0, 0, 0, 0 0.00,0.00) A( 119, 32 236, 98 255 0, 0, 0, 0 0.00,0.00) ... - 14 B( 10, 51 125, 116 255 0, 0, 0, 0 0.00,0.00) A( 104, 24 223, 91 255 0, 0, 0, 0 0.00,0.00) ... - 15 B( 30, 64 141, 126 255 0, 0, 0, 0 0.00,0.00) A( 88, 17 215, 88 255 0, 0, 0, 0 0.00,0.00) ... - 16 B( 55, 74 158, 132 255 0, 0, 0, 0 0.00,0.00) A( 72, 11 210, 89 255 0, 0, 0, 0 0.00,0.00) ... - 17 B( 80, 83 174, 136 255 0, 0, 0, 0 0.00,0.00) A( 57, 7 210, 93 255 0, 0, 0, 0 0.00,0.00) ... - 18 B( 106, 90 189, 137 255 0, 0, 0, 0 0.00,0.00) A( 43, 4 213, 100 255 0, 0, 0, 0 0.00,0.00) ... - 19 B( 131, 96 203, 136 255 0, 0, 0, 0 0.00,0.00) A( 30, 2 217, 107 255 0, 0, 0, 0 0.00,0.00) ... - 20 B( 154, 101 215, 135 255 0, 0, 0, 0 0.00,0.00) A( 19, 1 223, 116 255 0, 0, 0, 0 0.00,0.00) ... - 21 B( 172, 105 224, 134 255 0, 0, 0, 0 0.00,0.00) A( 11, 0 230, 123 255 0, 0, 0, 0 0.00,0.00) ... - 22 B( 187, 107 231, 132 255 0, 0, 0, 0 0.00,0.00) A( 5, 0 235, 130 255 0, 0, 0, 0 0.00,0.00) ... - 23 B( 195, 109 235, 131 255 0, 0, 0, 0 0.00,0.00) A( 1, 0 239, 134 255 0, 0, 0, 0 0.00,0.00) ... - 24 * B( 199, 110 237, 131 255 0, 0, 0, 0 0.00,0.00) A( 0, 0 240, 135 255 0, 0, 0, 0 0.00,0.00) pip -``` - -As you can see the triple verbose mode (using option `-vvv`) prints out a list of loaded composites and found transitions too, like option `-l` does. - -Additionally it prints out: -- the composites used to request the transition `pip -> pip` including a mark `(swapped)` at the right margin indicates that this transition has the same begin and end frame and so it will be swapped, -- the searched transitions (in this case there is only one), -- the long table which shows the calculated animation for this transition and all it's properties, -- the _flipping point_ at `--- FLIP SOURCES ---` from which on the letters for A and B are swapped and -- also the `*` signs which mark the used key frames from the composites out of the configuration. - -#### Transition Table - -To examine the automatically generated composites and transitions you can print out the transition table with `-m`: - -``` -▶ python3 testtransition.py -m -transition table: - fs-a pip sbs sbsp fs-b ^sbsp ^sbs - - fs-a Φ(def(fs-a/fs-a)) fs-a-pip fs-a-sbs fs-a-sbsp fs-fs ^fs-b-sbsp ^fs-b-sbs - pip fs-a-pip⁻¹ Φ(pip-pip) def(pip/sbs) def(pip/sbsp) fs-b-pip⁻¹ def(pip/^sbsp) def(pip/^sbs) - sbs fs-a-sbs⁻¹ def(sbs/pip) Φ(sbs-sbs) def(sbs/sbsp) fs-b-sbs⁻¹ def(sbs/^sbsp) Φ(_sbs-sbs⁻¹) - sbsp fs-a-sbsp⁻¹ def(sbsp/pip) def(sbsp/sbs) Φ(def(sbsp/sbsp)) fs-b-sbsp⁻¹ Φ(def(sbsp/^sbsp)) def(sbsp/^sbs) - fs-b Φ(fs-fs⁻¹) Φ(fs-b-pip) fs-b-sbs fs-b-sbsp Φ(^fs-fs) ^fs-a-sbsp ^fs-a-sbs -^sbsp ^fs-b-sbsp⁻¹ def(^sbsp/pip) def(^sbsp/sbs) Φ(def(^sbsp/sbsp)) ^fs-a-sbsp⁻¹ Φ(def(^sbsp/^sbsp)) def(^sbsp/^sbs) - ^sbs ^fs-b-sbs⁻¹ def(^sbs/pip) Φ(_sbs-sbs) def(^sbs/sbsp) ^fs-a-sbs⁻¹ def(^sbs/^sbsp) Φ(^sbs-sbs) - -``` - -The table has rows of all configured start target composites and columns of end target composites. -Every cell includes the available transitions. - -### Code - -#### main program -The program consists of several functions which are called from a main block: - -```python -read_argumentc() -init_log() -render_sequence(*read_config("composite.ini")) -``` -`render_sequence()` takes exactly what `read_config()` is delivering. - -#### read_argumentc() -Reads command line arguments like described above. -```python -def read_argumentc(): -``` -Fills global `Args` with the parser result. - -#### init_log() -Initializes debug logging. -```python -def init_log(): -``` -Global `log` gets the logger instance. - -#### read_config() -Read from the given file. -```python -def read_config(filename): -``` -`filename` is the name of the configuration file. - -#### render_compositec() - -Renders pictures of all `composites` and saves them into PNG files. - -```python -def render_compositec(size, composites): -``` -Produces images of the given `size`. - -#### render_sequence() - -Render all transitions between all items in the given sequence by using `safe_transition_gif()` (see below). - -```python -def render_sequence(size, fps, targets, transitions, composites): -``` - -Sequence is defined by the names listed in `targets`. -Produces images of the given `size`. -Calculate with `fps` frames per second and use the `transitions` and `composites` dictionaries to find matching transitions. - -#### save_transition_gif() - -Generates an animated GIF of the given name of an animation by using `draw_transition()` (see below) - -```python -def save_transition_gif(filename, size, name, animation, time): -``` - -`filename` is the name of the resulting file, `size` it's dimensions, `name` the displayed title, `animation` the transition to render and `time` the duration of that whole animation in the GIF. - -#### draw_composite() -Function that draws one composite and returns an image. - -```python -def draw_composite(size, composite, swap=False): -``` - -Produces images of `composite` in the given `size` which can be drawn swapped by using `swap`. - -#### draw_transition() -Internal function that draws one transition and returns a list of images. - -```python -def draw_transition(size, transition, name=None): -``` - -Produces images of `transition` in the given `size`. - -## TODO -#### Integration into exisiting _voctomix_ - -To get out video transition effect within _voctomix_ the configuration needs a format update and the compositor must be extended by the ability to switch the compositing scenario quickly frame by frame synchronized with the play time. - -#### Review the coordinate formats -...in RECT and CROP so we do not hurt someone's feelings. - -### Future Development - -- May be have just one `configure()` in `Transitions` which returns both composites and transitions so that you only need to import the Transitions interface instead of additionally the composites interface. -- Decide in which way three source scenarios like *c*(A1,B) ↔ *c*(A2,B) or *c*(A,B1) ↔ *c*(A,B2) can profite from any kind of specialized transitions. -- What about unlimited sources? -- add additional composite operations (like visual mirroring)? diff -Nru voctomix-1.3+git20200101/schedule.xml voctomix-1.3+git20200102/schedule.xml --- voctomix-1.3+git20200101/schedule.xml 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/schedule.xml 1970-01-01 00:00:00.000000000 +0000 @@ -1,37 +0,0 @@ - - - - - - 2019-01-01T10:00:00+02:00 - 01:00 - HALL 1 - Interesting talk in HALL 1 at 10:00 - - Alice - Bob - Claire - - - - 2019-01-01T10:00:00+02:00 - 01:00 - HALL 2 - Interesting talk in HALL 2 at 10:00 - - Dick - - - - 2019-01-01T11:15:00+02:00 - 01:00 - HALL 2 - Interesting talk in HALL 2 at 11:15 - - Alice - Dick - - - - - diff -Nru voctomix-1.3+git20200101/vocto/audio_streams.py voctomix-1.3+git20200102/vocto/audio_streams.py --- voctomix-1.3+git20200101/vocto/audio_streams.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/audio_streams.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -import re -import logging - -log = logging.getLogger('AudioStreams') - - -class AudioStreams(list): - - def __init__(self): - ''' just init the container ''' - self = [] - - def configure(cfg, source, use_source_as_name=False): - ''' create an instance of for that gets configured by INI section - If is True items will be named as . - ''' - audio_streams = AudioStreams() - - # walk through all items within the configuration string - for t_name, t in cfg: - # search for entrys like 'audio.*' - r = re.match(r'^audio\.([\w\-_]+)$', t_name) - if r: - for i, channel in enumerate(t.split("+")): - audio_streams.append(AudioStream(source, i, - source if use_source_as_name else r.group(1), channel) - ) - return audio_streams - - def __str__(self): - result = "" - for index, audio_stream in enumerate(self): - result += "mix.%d: %s.%d = %s.%d\n" % ( - index, audio_stream.name, audio_stream.channel, audio_stream.source, audio_stream.source_channel) - return result - - def source_channels(self, source): - ''' Return all audio channels by source. - ''' - # collect source's channels into a set and count them - return [audio_stream.source_channel for audio_stream in self if audio_stream.source == source] - - def num_channels(self, source, grid=[x for x in range(0, 255)]): - ''' Return the number of different audio channels overall or by source. - Filter by if given. - Round up to values in to match external capabilities. - ''' - # collect source's channels into a set and count them - channels = self.source_channels(source) - result = max(channels) + 1 if channels else 0 - # fill up to values in grid - while result not in grid: - result += 1 - return result - - def matrix(self, source, stream=None, out_channels=None, grid=[x for x in range(0, 255)]): - ''' Return matrix that maps in to out channels of . - Filter by if given. - Fill matrix up to rows if given. - Round up number of matrix columns to values in to match - external capabilities. - ''' - # collect result rows - result = [] - for out, audio_stream in enumerate(self): - row = [] - # build result row based on number of channels in that source - for ch in range(0, self.num_channels(source, grid)): - # map source channels to out channels - row.append(1.0 - if audio_stream.source == source - and audio_stream.source_channel == ch - and (stream is None or stream == audio_stream.name) else - 0.0) - result.append(row) - # if out channels are given - if out_channels: - # warn if source has more channels than out channels are given - if out_channels < len(result): - log.error("too many audio channels in source %s", source) - else: - # append rows up to out_channels - result += [[0.0] * - self.num_channels(source, grid)] * (out_channels - len(result)) - return result - - def get_source_streams(self, source): - ''' filter all stream channels of given ''' - result = {} - for audio_stream in self: - if source == audio_stream.source: - if audio_stream.name not in result: - result[audio_stream.name] = [] - result[audio_stream.name].append(audio_stream) - return result - - def get_stream_names(self, source=None): - ''' Get names of all streams. - Filter by if given. - ''' - result = [] - for audio_stream in self: - if not source or source == audio_stream.source: - if audio_stream.name not in result: - result.append(audio_stream.name) - return result - - def get_stream_source(self, source=None): - ''' Get sources of all streams. - Filter by if given. - ''' - result = [] - for audio_stream in self: - if not source or source == audio_stream.source: - if audio_stream.name not in result: - result.append(audio_stream.source) - return result - -class AudioStream: - def __init__(self, source, channel, name, source_channel): - ''' initialize stream data ''' - self.source = source - self.channel = int(channel) - self.name = name - self.source_channel = int(source_channel) diff -Nru voctomix-1.3+git20200101/vocto/command_helpers.py voctomix-1.3+git20200102/vocto/command_helpers.py --- voctomix-1.3+git20200101/vocto/command_helpers.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/command_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,10 +0,0 @@ -def quote(str): - ''' encode spaces and comma ''' - return None if not str else str.replace('\\', '\\\\').replace(' ','\\s').replace('|','\\v').replace(',','\\c').replace('\n','\\n') - -def dequote(str): - ''' decode spaces and comma ''' - return None if not str else str.replace('\\n','\n').replace('\\c', ',').replace('\\v', '|').replace('\\s', ' ').replace('\\\\', '\\') - -def str2bool(str): - return str.lower() in [ 'true', 'yes', 'visible', 'show', '1' ] diff -Nru voctomix-1.3+git20200101/vocto/composite_commands.py voctomix-1.3+git20200102/vocto/composite_commands.py --- voctomix-1.3+git20200101/vocto/composite_commands.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/composite_commands.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -import re - - -class CompositeCommand: - - def __init__(self, composite, A, B): - self.composite = composite - self.A = A - self.B = B - - def from_str(command): - A = None - B = None - # match case: c(A,B) - r = re.match( - r'^\s*([|+-]?\w[-_\w]*)\s*\(\s*([-_\w*]+)\s*,\s*([-_\w*]+)\)\s*$', command) - if r: - A = r.group(2) - B = r.group(3) - else: - # match case: c(A) - r = re.match(r'^\s*([|+-]?\w[-_\w]*)\s*\(\s*([-_\w*]+)\s*\)\s*$', command) - if r: - A = r.group(2) - else: - # match case: c - r = re.match(r'^\s*([|+-]?\w[-_\w]*)\s*$', command) - assert r - composite = r.group(1) - if composite == '*': - composite = None - if A == '*': - A = None - if B == '*': - B = None - return CompositeCommand(composite,A,B) - - def modify(self, mod, reverse=False): - # get command as string and process all replactions - command = original = str(self) - for r in mod.split(','): - what, _with = r.split('->') - if reverse: - what, _with = _with, what - command = command.replace(what.strip(), _with.strip()) - modified = original != command - # re-convert string to command and take the elements - command = CompositeCommand.from_str(command) - self.composite = command.composite - self.A = command.A - self.B = command.B - return modified - - def unmodify(self, mod): - return self.modify(mod, True) - - def __str__(self): - return "%s(%s,%s)" % (self.composite if self.composite else "*", - self.A if self.A else "*", - self.B if self.B else "*") - - def __eq__(self, other): - return ((self.composite == other.composite or not(self.composite and other.composite)) - and (self.A == other.A or not(self.A and other.A)) - and (self.B == other.B or not(self.B and other.B))) diff -Nru voctomix-1.3+git20200101/vocto/composites.py voctomix-1.3+git20200102/vocto/composites.py --- voctomix-1.3+git20200101/vocto/composites.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/composites.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,367 +0,0 @@ -#!/usr/bin/env python3 -# for debug logging -import logging -# use Frame -from vocto.frame import Frame, X, Y, L, T, R, B -# for cloning objects -import copy -# for parsing configuration items -import re - -log = logging.getLogger('Composites') - - -class Composites: - """ a namespace for composite related methods - """ - - def configure(cfg, size, add_swap=True): - """ read INI like configuration from and return all the defined - composites. is the overall frame size which all proportional - (floating point) coordinates are related to. - """ - # prepare resulting composites dictonary - composites = dict() - # walk through composites configuration - for c_name, c_val in cfg: - if '.' not in c_name: - raise RuntimeError("syntax error in composite config '{}' " - "(must be: 'name.attribute')" - .format(c_name)) - # split name into name and attribute - name, attr = c_name.lower().rsplit('.', 1) - if name not in composites: - # add new composite - composites[name] = Composite(len(composites), name) - try: - # set attribute - composites[name].config(attr, c_val, size) - except RuntimeError as err: - raise RuntimeError( - "syntax error in composite config value at '{}':\n{}" - .format(name, err)) - add_mirrored_composites(composites) - if add_swap: - # add any useful swapped targets - add_swapped_targets(composites) - return composites - - def targets(composites): - """ return a list of all composites that are not intermediate - """ - result = [] - for c_name, c in composites.items(): - if not c.inter: - result.append(c) - return sorted(result, key=lambda c: c.order) - - def intermediates(composites): - """ return a list of all composites that are intermediate - """ - result = [] - for c_name, c in composites.items(): - if c.inter: - result.append(c) - return sorted(result, key=lambda c: c.order) - - -class Composite: - - def __init__(self, order, name, a=Frame(True), b=Frame(True)): - assert type(order) is int or order is None - assert type(name) is str or not name - self.name = name - self.frame = [copy.deepcopy(a), copy.deepcopy(b)] - self.default = [None, None] - self.inter = False - self.noswap = False - self.mirror = False - self.order = order - - def str_title(): - return "Key A%s\tB%s Name" % (Frame.str_title(), Frame.str_title()) - - def __str__(self): - def hidden( x, hidden ): - return str(x).replace(' ','_') if hidden else str(x) - - return "%s A%s\tB%s %s" % (" * " if self.A().key else " ", - hidden(self.A(), self.A().invisible() or self.covered()), - hidden(self.B(), self.B().invisible()), - self.name) - - def equals(self, other, treat_covered_as_invisible, swapped=False): - """ compare two composites if they are looking the same - (e.g. a rectangle with size 0x0=looks the same as one with alpha=0 - and so it is treated as equal here) - """ - if not swapped: - if not (self.A() == other.A() or (treat_covered_as_invisible and self.covered() and other.covered())): - return False - elif not (self.B() == other.B() or (self.B().invisible() and other.B().invisible())): - return False - else: - if not (self.A() == other.B() or (treat_covered_as_invisible and self.covered() and other.B().invisible())): - return False - elif not (self.B() == other.A() or (self.B().invisible() and other.covered())): - return False - return True - - def A(self): - return self.frame[0] - - def B(self): - return self.frame[1] - - def Az(self, zorder): - frame = copy.deepcopy(self.frame[0]) - frame.zorder = zorder - return frame - - def Bz(self, zorder): - frame = copy.deepcopy(self.frame[1]) - frame.zorder = zorder - return frame - - def swapped(self): - """ swap A and B source items - """ - if self.noswap: - return self - else: - # deep copy everything - s = copy.deepcopy(self) - # then swap frames - s.frame = self.frame[::-1] - s.name = swap_name(self.name) - return s - - def mirrored(self): - """ mirror A and B source items - """ - # deep copy everything - s = copy.copy(self) - # then mirror frames - s.frame = [f.mirrored() for f in self.frame] - s.name = mirror_name(self.name) - return s - - def key(self): - for f in self.frame: - if f.key: - return True - return False - - def config(self, attr, value, size): - """ set value from INI attribute . - is the input channel size - """ - if attr == 'a': - self.frame[0].rect = str2rect(value, size) - elif attr == 'b': - self.frame[1].rect = str2rect(value, size) - elif attr == 'crop-a': - self.frame[0].crop = str2crop(value, size) - elif attr == 'crop-b': - self.frame[1].crop = str2crop(value, size) - elif attr == 'default-a': - self.default[0] = value - elif attr == 'default-b': - self.default[1] = value - elif attr == 'alpha-a': - self.frame[0].alpha = str2alpha(value) - elif attr == 'alpha-b': - self.frame[1].alpha = str2alpha(value) - elif attr == 'inter': - self.inter = value - elif attr == 'noswap': - self.noswap = value - elif attr == 'mirror': - self.mirror = value - self.frame[0].original_size = size - self.frame[1].original_size = size - - def covered(self): - """ check if below (A) is invisible or covered by above (B) - (considers shape with cropping and transparency) - """ - below, above = self.frame - if below.invisible(): - return True - if above.invisible(): - return False - bc = below.cropped() - ac = above.cropped() - # return if above is (semi-)transparent or covers below completely - return (above.alpha == 255 and - bc[L] >= ac[L] and - bc[T] >= ac[T] and - bc[R] <= ac[R] and - bc[B] <= ac[B]) - - def single(self): - """ check if above (B) is invisible - """ - below, above = self.frame - return above.invisible() - - def both(self): - return not (single() or covered()) - - -def add_swapped_targets(composites): - result = dict() - for c_name, c in composites.items(): - if not (c.inter or c.noswap): - inc = True - for v_name, v in composites.items(): - if v.equals(c.swapped(), True) and not v.inter: - inc = False - break - if inc: - log.debug("Adding auto-swapped target %s from %s" % - (swap_name(c_name), c_name)) - r = c.swapped() - r.order = len(composites) + len(result) - result[swap_name(c_name)] = r - return composites.update(result) - -def add_mirrored_composites(composites): - result = dict() - for c_name, c in composites.items(): - if c.mirror: - r = c.mirrored() - r.order = len(composites) + len(result) - result[mirror_name(c_name)] = r - return composites.update(result) - - -def swap_name(name): return name[1:] if name[0] == '^' else "^" + name -def mirror_name(name): return name[1:] if name[0] == '|' else "|" + name - - -def absolute(str, max): - if str == '*': - assert max - # return maximum value - return int(max) - elif '.' in str: - assert max - # return absolute (Pixel) value in proportion to max - return int(float(str) * max) - else: - # return absolute (Pixel) value - return int(str) - - -def str2rect(str, size): - """ read rectangle pair from string '*', 'X/Y WxH', 'X/Y', 'WxH', 'X/Y WH', 'X/Y WH' or 'XY WH' - """ - # check for '*' - if str == "*": - # return overall position and size - return [0, 0, size[X], size[Y]] - - # check for 'X/Y' - r = re.match(r'^\s*([-.\d]+)\s*/\s*([-.\d]+)\s*$', str) - if r: - # return X,Y and overall size - return [absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y]), - size[X], - size[Y]] - # check for 'WxH' - r = re.match(r'^\s*([.\d]+)\s*x\s*([.\d]+)\s*$', str) - if r: - # return overall pos and W,H - return [0, - 0, - absolute(r.group(3), size[X]), - absolute(r.group(4), size[Y])] - # check for 'X/Y WxH' - r = re.match( - r'^\s*([-.\d]+)\s*/\s*([-.\d]+)\s+([.\d]+)\s*x\s*([.\d]+)\s*$', str) - if r: - # return X,Y,X+W,Y+H - return [absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y]), - absolute(r.group(1), size[X]) + absolute(r.group(3), size[X]), - absolute(r.group(2), size[Y]) + absolute(r.group(4), size[Y])] - # check for 'XY WxH' - r = re.match(r'^\s*(-?\d+.\d+)\s+([.\d]+)\s*x\s*([.\d]+)\s*$', str) - if r: - # return XY,XY,XY+W,XY+H - return [absolute(r.group(1), size[X]), - absolute(r.group(1), size[Y]), - absolute(r.group(1), size[X]) + absolute(r.group(2), size[X]), - absolute(r.group(1), size[Y]) + absolute(r.group(3), size[Y])] - # check for 'X/Y WH' - r = re.match(r'^\s*([-.\d]+)\s*/\s*([-.\d]+)\s+(\d+.\d+)\s*$', str) - if r: - # return X,Y,X+WH,Y+WH - return [absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y]), - absolute(r.group(1), size[X]) + absolute(r.group(3), size[X]), - absolute(r.group(2), size[Y]) + absolute(r.group(3), size[Y])] - # check for 'XY WH' - r = re.match(r'^\s*(-?\d+.\d+)\s+(\d+.\d+)\s*$', str) - if r: - # return XY,XY,XY+WH,XY+WH - return [absolute(r.group(1), size[X]), - absolute(r.group(1), size[Y]), - absolute(r.group(1), size[X]) + absolute(r.group(2), size[X]), - absolute(r.group(1), size[Y]) + absolute(r.group(2), size[Y])] - # didn't get it - raise RuntimeError("syntax error in rectangle value '{}' " - "(must be either '*', 'X/Y WxH', 'X/Y', 'WxH', 'X/Y WH', 'X/Y WH' or 'XY WH' where X, Y, W, H may be int or float and XY, WH must be float)".format(str)) - - -def str2crop(str, size): - """ read crop values pair from string '*' or 'L/T/R/B' - """ - # check for '*' - if str == "*": - # return zero borders - return [0, 0, 0, 0] - # check for L/T/R/B - r = re.match( - r'^\s*([.\d]+)\s*/\s*([.\d]+)\s*/\s*([.\d]+)\s*/\s*([.\d]+)\s*$', str) - if r: - return [absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y]), - absolute(r.group(3), size[X]), - absolute(r.group(4), size[Y])] - # check for LR/TB - r = re.match( - r'^\s*([.\d]+)\s*/\s*([.\d]+)\s*$', str) - if r: - return [absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y]), - absolute(r.group(1), size[X]), - absolute(r.group(2), size[Y])] - # check for LTRB - r = re.match( - r'^\s*([.\d]+)\s*$', str) - if r: - return [absolute(r.group(1), size[X]), - absolute(r.group(1), size[Y]), - absolute(r.group(1), size[X]), - absolute(r.group(1), size[Y])] - # didn't get it - raise RuntimeError("syntax error in crop value '{}' " - "(must be either '*', 'L/T/R/B', 'LR/TB', 'LTRB' where L, T, R, B, LR/TB and LTRB must be int or float')".format(str)) - - -def str2alpha(str): - """ read alpha values from string as float between 0.0 and 1.0 or as int between 0 an 255 - """ - # check for floating point value - r = re.match( - r'^\s*([.\d]+)\s*$', str) - if r: - # return absolute proportional to 255 - - return absolute(r.group(1), 255) - # didn't get it - raise RuntimeError("syntax error in alpha value '{}' " - "(must be float or int)".format(str)) diff -Nru voctomix-1.3+git20200101/vocto/config.py voctomix-1.3+git20200102/vocto/config.py --- voctomix-1.3+git20200101/vocto/config.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/config.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,388 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re -import os - -from gi.repository import Gst -from configparser import SafeConfigParser -from lib.args import Args -from vocto.transitions import Composites, Transitions -from vocto.audio_streams import AudioStreams - -testPatternCount = 0 - -GST_TYPE_VIDEO_TEST_SRC_PATTERN = [ - "smpte", - "ball", - "red", - "green", - "blue", - "black", - "white", - "checkers-1", - "checkers-2", - "checkers-4", - "checkers-8", - "circular", - "blink", - "smpte75", - "zone-plate", - "gamut", - "chroma-zone-plate", - "solid-color", - "smpte100", - "bar", - "snow", - "pinwheel", - "spokes", - "gradient", - "colors" -] - -GST_TYPE_AUDIO_TEST_SRC_WAVE = [ - "sine", - "square", - "saw", - "triangle", - "silence", - "white-noise", - "pink-noise", - "sine-table", - "ticks", - "gaussian-noise", - "red-noise", - "blue-noise", - "violet-noise", -] - - -class VocConfigParser(SafeConfigParser): - - log = logging.getLogger('VocConfigParser') - - def getList(self, section, option, fallback=None): - if self.has_option(section, option): - option = self.get(section, option).strip() - if len(option) == 0: - return [] - - unfiltered = [x.strip() for x in option.split(',')] - return list(filter(None, unfiltered)) - else: - return fallback - - def getSources(self): - return self.getList('mix', 'sources') - - def getAudioSource(self): - return self.get('mix', 'audiosource', fallback=None) - - def getLiveSources(self): - return ["mix"] + self.getList('mix', 'livesources', []) - - def getBackgroundSources(self): - if self.has_option('mix', 'backgrounds'): - return self.getList('mix', 'backgrounds') - else: - return ["background"] - - def getBackgroundSource(self,composite): - for source in self.getBackgroundSources(): - if composite in self.getList('source.{}'.format(source), 'composites', fallback=[]): - return source - return self.getBackgroundSources()[0] - - def getSourceKind(self, source): - return self.get('source.{}'.format(source), 'kind', fallback='test') - - def getNoSignal(self): - nosignal = self.get('mix', 'nosignal', fallback='smpte100').lower() - if nosignal in ['none','false','no']: - return None - elif nosignal in GST_TYPE_VIDEO_TEST_SRC_PATTERN: - return nosignal - else: - self.log.error("Configuration value mix/nosignal has unknown pattern '{}'".format(nosignal)) - - def getDeckLinkDeviceNumber(self, source): - return self.getint('source.{}'.format(source), 'devicenumber', fallback=0) - - def getDeckLinkAudioConnection(self, source): - return self.get('source.{}'.format(source), 'audio_connection', fallback='auto') - - def getDeckLinkVideoConnection(self, source): - return self.get('source.{}'.format(source), 'video_connection', fallback='auto') - - def getDeckLinkVideoMode(self, source): - return self.get('source.{}'.format(source), 'video_mode', fallback='auto') - - def getDeckLinkVideoFormat(self, source): - return self.get('source.{}'.format(source), 'video_format', fallback='auto') - - def getV4l2Device(self, source): - return self.get('source.{}'.format(source), 'device', fallback='/dev/video0') - - def getV4l2Width(self, source): - return self.get('source.{}'.format(source), 'width', fallback=1920) - - def getV4l2Height(self, source): - return self.get('source.{}'.format(source), 'height', fallback=1080) - - def getV4l2Format(self, source): - return self.get('source.{}'.format(source), 'format', fallback='YUY2') - - def getV4l2Framerate(self, source): - return self.get('source.{}'.format(source), 'framerate', fallback='25/1') - - def getImageURI(self,source): - if self.has_option('source.{}'.format(source), 'imguri'): - return self.get('source.{}'.format(source), 'imguri') - else: - path = os.path.abspath(self.get('source.{}'.format(source), 'file')) - if not os.path.isfile(path): - self.log.error("image file '%s' could not be found" % path) - return "file://{}".format(path) - - def getLocation(self,source): - return self.get('source.{}'.format(source), 'location') - - def getLoop(self,source): - return self.get('source.{}'.format(source), 'loop', fallback="true") - - def getTestPattern(self, source): - if not self.has_section('source.{}'.format(source)): - # default blinder source shall be smpte (if not defined otherwise) - if source == "blinder": - return "smpte" - # default background source shall be black (if not defined otherwise) - if source in self.getBackgroundSources(): - return "black" - - pattern = self.get('source.{}'.format(source), 'pattern', fallback=None) - if not pattern: - global testPatternCount - testPatternCount += 1 - pattern = GST_TYPE_VIDEO_TEST_SRC_PATTERN[testPatternCount % len(GST_TYPE_VIDEO_TEST_SRC_PATTERN)] - self.log.info("Test pattern of source '{}' unspecified, picking '{} ({})'" - .format(source,pattern, testPatternCount)) - return pattern - - def getTestWave(self, source): - if not self.has_section('source.{}'.format(source)): - # background needs no sound, blinder should have no sound - if source == "blinder" or source == "background": - return "silence" - - return self.get('source.{}'.format(source), 'wave', fallback="sine") - - def getSourceScan(self, source): - section = 'source.{}'.format(source) - if self.has_option(section, 'deinterlace'): - self.log.error("source attribute 'deinterlace' is obsolete. Use 'scan' instead! Falling back to 'progressive' scheme") - return self.get(section, 'scan', fallback='progressive') - - def getAudioStreams(self): - audio_streams = AudioStreams() - sources = self.getSources() - for source in sources: - section = 'source.{}'.format(source) - if self.has_section(section): - audio_streams += AudioStreams.configure(self.items(section), source) - return audio_streams - - def getBlinderAudioStreams(self): - audio_streams = AudioStreams() - section = 'source.blinder' - if self.has_section(section): - audio_streams += AudioStreams.configure(self.items(section), "blinder", use_source_as_name=True) - return audio_streams - - def getAudioStream(self, source): - section = 'source.{}'.format(source) - if self.has_section(section): - return AudioStreams.configure(self.items(section), source) - return AudioStreams() - - def getNumAudioStreams(self): - num_audio_streams = len(self.getAudioStreams()) - if self.getAudioChannels() < num_audio_streams: - self.log.error("number of audio channels in mix/audiocaps differs from the available audio input channels within the sources!") - return num_audio_streams - - def getVideoCaps(self): - return self.get('mix', 'videocaps', fallback="video/x-raw,format=I420,width=1920,height=1080,framerate=25/1,pixel-aspect-ratio=1/1") - - def getAudioCaps(self, section='mix'): - return self.get(section, 'audiocaps', fallback="audio/x-raw,format=S16LE,channels=2,layout=interleaved,rate=48000") - - def getAudioChannels(self): - caps = Gst.Caps.from_string( - self.getAudioCaps()).get_structure(0) - _, channels = caps.get_int('channels') - return channels - - def getVideoResolution(self): - caps = Gst.Caps.from_string( - self.getVideoCaps()).get_structure(0) - _, width = caps.get_int('width') - _, height = caps.get_int('height') - return (width, height) - - def getVideoRatio(self): - width, height = self.getVideoResolution() - return float(width)/float(height) - - def getFramerate(self): - caps = Gst.Caps.from_string( - self.getVideoCaps()).get_structure(0) - (_, numerator, denominator) = caps.get_fraction('framerate') - return (numerator, denominator) - - def getFramesPerSecond(self): - num, denom = self.getFramerate() - return float(num) / float(denom) - - def getVideoSystem(self): - return self.get('videodisplay', 'system', fallback='gl') - - def getPlayAudio(self): - return self.getboolean('audio', 'play', fallback=False) - - def getVolumeControl(self): - # Check if there is a fixed audio source configured. - # If so, we will remove the volume sliders entirely - # instead of setting them up. - return (self.getboolean('audio', 'volumecontrol', fallback=True) - or self.getboolean('audio', 'forcevolumecontrol', fallback=False)) - - def getBlinderEnabled(self): - return self.getboolean('blinder', 'enabled', fallback=False) - - def isBlinderDefault(self): - return not self.has_option('blinder', 'videos') - - def getBlinderSources(self): - if self.getBlinderEnabled(): - if self.isBlinderDefault(): - return ["blinder"] - else: - return self.getList('blinder', 'videos') - else: - return [] - - def getBlinderVolume(self): - return self.getfloat('source.blinder', 'volume', fallback=1.0) - - def getMirrorsEnabled(self): - return self.getboolean('mirrors', 'enabled', fallback=False) - - def getMirrorsSources(self): - if self.getMirrorsEnabled(): - if self.has_option('mirrors', 'sources'): - return self.getList('mirrors', 'sources') - else: - return self.getSources() - else: - return [] - - def getOutputBuffers(self, channel): - return self.getint('output-buffers', channel, fallback=500) - - def getPreviewVaapi(self): - if self.has_option('previews', 'vaapi'): - return self.get('previews', 'vaapi') - return None - - def getDenoiseVaapi(self): - if self.has_option('previews', 'vaapi-denoise'): - if self.getboolean('previews', 'vaapi-denoise'): - return 1 - return 0 - - def getScaleMethodVaapi(self): - if self.has_option('previews', 'scale-method'): - return self.getint('previews', 'scale-method') - return 0 - - def getPreviewCaps(self): - if self.has_option('previews', 'videocaps'): - return self.get('previews', 'videocaps') - else: - return self.getVideoCaps() - - def getPreviewSize(self): - width = self.getint('previews', 'width') if self.has_option( - 'previews', 'width') else 320 - height = self.getint('previews', 'height') if self.has_option( - 'previews', 'height') else int(width * 9 / 16) - return(width, height) - - def getPreviewFramerate(self): - caps = Gst.Caps.from_string( - self.getPreviewCaps()).get_structure(0) - (_, numerator, denominator) = caps.get_fraction('framerate') - return (numerator, denominator) - - def getPreviewResolution(self): - caps = Gst.Caps.from_string( - self.getPreviewCaps()).get_structure(0) - _, width = caps.get_int('width') - _, height = caps.get_int('height') - return (width, height) - - def getDeinterlacePreviews(self): - return self.getboolean('previews', 'deinterlace', fallback=False) - - def getPreviewsEnabled(self): - return self.getboolean('previews', 'enabled', fallback=False) - - def getLivePreviews(self): - if self.getBlinderEnabled(): - singleval = self.get('previews', 'live').lower() - if singleval in [ "true", "yes" ]: - return ["mix"] - if singleval == "all": - return self.getLiveSources() - previews = self.getList('previews', 'live') - result = [] - for preview in previews: - if preview not in self.getLiveSources(): - self.log.error("source '{}' configured in 'preview/live' must be listed in 'mix/livesources'!".format(preview)) - else: - result.append(preview) - return result - else: - self.log.warning("configuration attribute 'preview/live' is set but blinder is not in use!") - return [] - - def getPreviewDecoder(self): - if self.has_option('previews', 'vaapi'): - return self.get('previews', 'vaapi') - else: - return 'jpeg' - - def getComposites(self): - return Composites.configure(self.items('composites'), self.getVideoResolution()) - - def getTargetComposites(self): - return Composites.targets(self.getComposites()) - - def getTransitions(self, composites): - return Transitions.configure(self.items('transitions'), - composites, - fps=self.getFramesPerSecond()) - - def getPreviewNameOverlay(self): - return self.getboolean('previews', 'nameoverlay', fallback=True) - - def hasSource(self, source): - return self.has_section('source.{}'.format(source)) - - def hasOverlay(self): - return self.has_section('overlay') - - def getOverlayAutoOff(self): - return self.getboolean('overlay', 'auto-off', fallback=True) - - def getOverlayUserAutoOff(self): - return self.getboolean('overlay', 'user-auto-off', fallback=False) diff -Nru voctomix-1.3+git20200101/vocto/debug.py voctomix-1.3+git20200102/vocto/debug.py --- voctomix-1.3+git20200101/vocto/debug.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/debug.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import os -import logging -from gi.repository import Gst -import gi -gi.require_version('Gst', '1.0') - -log = logging.getLogger('vocto.debug') - -def gst_generate_dot(pipeline, name): - from lib.args import Args - dotfile = os.path.join(os.environ['GST_DEBUG_DUMP_DOT_DIR'], "%s.dot" % name) - log.debug("Generating DOT image of pipeline '{name}' into '{file}'".format(name=name, file=dotfile)) - Gst.debug_bin_to_dot_file(pipeline, Gst.DebugGraphDetails(Args.gst_debug_details), name) - - -gst_log_messages_lastmessage = None -gst_log_messages_lastlevel = None -gst_log_messages_repeat = 0 - -def gst_log_messages(level): - - gstLog = logging.getLogger('Gst') - - def log( level, msg ): - if level == Gst.DebugLevel.WARNING: - gstLog.warning(msg) - if level == Gst.DebugLevel.FIXME: - gstLog.warning(msg) - elif level == Gst.DebugLevel.ERROR: - gstLog.error(msg) - elif level == Gst.DebugLevel.INFO: - gstLog.info(msg) - elif level == Gst.DebugLevel.DEBUG: - gstLog.debug(msg) - - def logFunction(category, level, file, function, line, object, message, *user_data): - global gst_log_messages_lastmessage, gst_log_messages_lastlevel, gst_log_messages_repeat - - msg = message.get() - if gst_log_messages_lastmessage != msg: - if gst_log_messages_repeat > 2: - log(gst_log_messages_lastlevel,"%s [REPEATING %d TIMES]" % (gst_log_messages_lastmessage, gst_log_messages_repeat)) - - gst_log_messages_lastmessage = msg - gst_log_messages_repeat = 0 - gst_log_messages_lastlevel = level - log(level,"%s: %s (in function %s() in file %s:%d)" % (object.name if object else "", msg, function, file, line)) - else: - gst_log_messages_repeat += 1 - - - Gst.debug_remove_log_function(None) - Gst.debug_add_log_function(logFunction,None) - Gst.debug_set_default_threshold(level) - Gst.debug_set_active(True) diff -Nru voctomix-1.3+git20200101/vocto/frame.py voctomix-1.3+git20200102/vocto/frame.py --- voctomix-1.3+git20200101/vocto/frame.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/frame.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -# for debug logging -import logging -# for cloning objects -import copy - -# substitute array coordinate mappings fer better reading -X, Y = 0, 1 -L, T, R, B = 0, 1, 2, 3 - -log = logging.getLogger('Frame') - - -class Frame: - - def __init__(self, key=False, alpha=255, zorder=None, rect=[0, 0, 0, 0], crop=[0, 0, 0, 0]): - self.rect = rect - self.crop = crop - self.alpha = alpha - self.original_size = [0.0, 0.0] - self.key = key - self.zorder = zorder - - def __repr__(self): - z = [round(x, 1) for x in self.zoom] - return ("{0.rect} {0.crop} {0.alpha} {1}").format(self, z) - - def str_title(): - return "( L, T R, B alpha LCRP,TCRP,RCRP,BCRP XZOM,YZOM, Z)" - - def __str__(self): - return ("(%4d,%4d %4d,%4d %4d %4d,%4d,%4d,%4d %1.2f,%1.2f, %2d)" % - tuple(self.rect + [self.alpha] + self.crop + self.zoom() + [self.zorder if self.zorder is not None else -1])) - - def __eq__(self, other): - # do NOT compare zoom - return self.rect == other.rect and self.crop == other.crop and self.alpha == other.alpha - - def zoomx(self): - """ calculate x-zoom factor from relation between given size and - width of rect in all channels - """ - if self.crop != [0, 0, 0, 0]: - return (self.rect[R] - self.rect[L]) / self.original_size[X] - return 0.0 - - def zoomy(self): - """ calculate zoom factors from relation between given size and - width and height of rect in all channels - """ - if self.crop != [0, 0, 0, 0]: - return (self.rect[B] - self.rect[T]) / self.original_size[Y] - return 0.0 - - - def zoom(self): - """ calculate zoom factors from relation between given size and - width and height of rect in all channels - """ - return [self.zoomx(), self.zoomy()] - - def cropped(self): - if not self.rect: - return None - return [self.rect[L] + self.crop[L] * self.zoomx(), - self.rect[T] + self.crop[T] * self.zoomy(), - self.rect[R] - self.crop[R] * self.zoomx(), - self.rect[B] - self.crop[B] * self.zoomy()] - - def corner(self, ix, iy): return [self.rect[ix], self.rect[iy]] - - def left(self): return self.rect[L] - - def top(self): return self.rect[T] - - def width(self): return self.rect[R] - self.rect[L] - - def height(self): return self.rect[B] - self.rect[T] - - def float_alpha(self): - return float(self.alpha)/255.0 - - def size(self): return self.width(), self.height() - - def invisible(self): - return (self.rect is None or - self.rect[R] == self.rect[L] or - self.rect[T] == self.rect[B] or - self.alpha == 0) - - def mirrored(self): - # deep copy everything - f = self.duplicate() - # then mirror frame - f.rect[L], f.rect[R] = f.original_size[X] - f.rect[R], f.original_size[X] - f.rect[L] - return f - - def duplicate(self): - return copy.deepcopy(self) diff -Nru voctomix-1.3+git20200101/vocto/__init__.py voctomix-1.3+git20200102/vocto/__init__.py --- voctomix-1.3+git20200101/vocto/__init__.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/__init__.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -import gi -gi.require_version('Gst', '1.0') -from gi.repository import Gst -import os - -# set GST debug dir for dot files -if not 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: - os.environ['GST_DEBUG_DUMP_DOT_DIR'] = os.getcwd() diff -Nru voctomix-1.3+git20200101/vocto/port.py voctomix-1.3+git20200102/vocto/port.py --- voctomix-1.3+git20200101/vocto/port.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/port.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -import json - - -class Port(object): - - NONE = 0 - - IN = 1 - OUT = 2 - - OFFSET_PREVIEW = 100 - # core listening port - CORE_LISTENING = 9999 - # input ports - SOURCES_IN = 10000 - SOURCES_BACKGROUND = 16000 - SOURCE_OVERLAY= 14000 - SOURCES_BLANK = 17000 - AUDIO_SOURCE_BLANK = 18000 - # output ports - MIX_OUT = 11000 - MIX_PREVIEW = MIX_OUT+OFFSET_PREVIEW - SOURCES_OUT = 13000 - SOURCES_PREVIEW = SOURCES_OUT+OFFSET_PREVIEW - LIVE_OUT = 15000 - LIVE_PREVIEW = LIVE_OUT+OFFSET_PREVIEW - - def __init__(self, name, source=None, audio=None, video=None): - self.name = name - self.source = source - self.audio = audio - self.video = video - self.update() - - def todict(self): - return { - 'name': self.name, - 'port': self.port, - 'audio': self.audio, - 'video': self.video, - 'io': self.io, - 'connections': self.connections - } - - def update(self): - if self.source: - self.port = self.source.port() - self.audio = self.source.audio_channels() - self.video = self.source.video_channels() - self.io = self.IN if self.source.is_input() else self.OUT - self.connections = self.source.num_connections() - - def from_str(_str): - p = Port(_str['name']) - p.port = _str['port'] - p.audio = _str['audio'] - p.video = _str['video'] - p.io = _str['io'] - p.connections = _str['connections'] - return p - - def is_input(self): - return self.io == Port.IN - - def is_output(self): - return self.io == Port.OUT diff -Nru voctomix-1.3+git20200101/vocto/pretty.py voctomix-1.3+git20200102/vocto/pretty.py --- voctomix-1.3+git20200101/vocto/pretty.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/pretty.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,26 +0,0 @@ -# for parsing configuration items -import re - -def pretty(pipe): - result = "" - for line in pipe.splitlines(): - line = line.strip() - r = re.match(r'^(!\s.*)$', line) - if r: - result += " " + r.group(1) - else: - r = re.match(r'^([\w\-_]*\s*\=\s*.*)$', line) - if r: - result += " " + r.group(1) - else: - r = re.match(r'^(\))$', line) - if r: - result += r.group(1) - else: - r = re.match(r'^bin.\($', line) - if r: - result += line - else: - result += " " + line - result += "\n" - return result diff -Nru voctomix-1.3+git20200101/vocto/transitions.py voctomix-1.3+git20200102/vocto/transitions.py --- voctomix-1.3+git20200101/vocto/transitions.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/vocto/transitions.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,551 +0,0 @@ -#!/usr/bin/env python3 -# for debug logging -import logging -from vocto.composites import Composite, Composites, swap_name -from vocto.frame import Frame, L, R, T, B, X, Y -# for calculating square roots -import math -# for generating B-Splines -from scipy import interpolate as spi -# for converting arrays -import numpy as np -# for cloning objects -import copy - -V = 2 # distance (velocity) index - -log = logging.getLogger('Transitions') -logKeyFramesOnly = True - -class Transitions: - """ transition table and interface - """ - - # interpolation resolution - HiRes = 0.001 - LoRes = 0.01 - resolution = HiRes - - def __init__(self, targets=[], fps=25): - self.transitions = [] - self.targets = targets - self.fps = fps - - def __str__(self): - """ write transition table into a string - """ - return "\n".join([t.name() for t in self.transitions]) - - def __len__(self): - return len(self.transitions) - - def count(self): - """ count available transition - """ - return len(self.transitions) - - def add(self,transition,frames): - # check if a compatible transition is already in our pool - for t in self.transitions: - if t.begin().equals(transition.begin(), True) and t.end().equals(transition.end(), True): - # skip if found - return - elif t.begin().equals(transition.end(), True) and t.end().equals(transition.begin(), True): - self.transitions.append(t.reversed()) - return - # otherwise calculate transition and add it to out pool - transition.calculate(frames - 1) - self.transitions.append(transition) - - def configure(cfg, composites, targets=None, fps=25): - """ generate all transitions configured in the INI-like configuration - string in by using the given and return them - in a dictonary - """ - def index(composite): - for i in range(len(targets)): - if composites[targets[i]].equals(composite, True): - return i - return None - - # filter target composites from given composites - if not targets: - targets = Composites.targets(composites) - # prepare result - transitions = Transitions(targets,fps) - - # walk through all items within the configuration string - for t_name, t in cfg: - # split animation time and composite sequence from t - time, sequence = t.split(',') - time = int(time) - # calculate frames needed for that animation time - frames = fps * float(time) / 1000.0 - # split sequence list into key frames - sequence = [x.strip().lower() for x in sequence.split('/')] - for seq in parse_asterisk(sequence, targets): - if "*" in sequence: - name = "%s(%s)" % (t_name, "/".join(seq)) - else: - name = t_name - # prepare list of key frame composites - transition = Transition(name) - try: - # walk trough composite sequence - for c_name in seq: - #c_name = c_name.lower() - if c_name[0] == '^': - # find a composite with that name - transition.append(composites[c_name[1:]].swapped()) - else: - # find a composite with that name - transition.append(composites[c_name]) - # log any failed find - except KeyError as err: - raise RuntimeError( - 'composite "{}" could not be found in transition {}'.format(err, name)) - transitions.add(transition,frames - 1) - log.debug("Loaded %d transitions from configuration.", len(transitions)) - # return dictonary - return transitions - - def solve(self, begin, end, flip): - log.debug("Solving transition %s(A,B) -> %s(%s)\n\t %s\n\t %s", begin.name, end.name, "B,A" if flip else "A,B", begin, end) - for transition in self.transitions: - # try to find original transition - if transition.begin().equals(begin, True) and transition.end().equals(end, True, flip): - log.debug("Solved #1 %s\n%s", transition.name(), transition) - return transition, False - if transition.begin().equals(begin, True, flip) and transition.end().equals(end, True): - log.debug("Solved #2 %s\n%s", transition.name(), transition) - return transition, True - # try reverse - if transition.begin().equals(end, True) and transition.end().equals(begin, True, flip): - log.debug("Solved #3 %s\n%s", transition.name(), transition) - return transition.reversed(), True - if transition.begin().equals(end, True, flip) and transition.end().equals(begin, True): - log.debug("Solved #4 %s\n%s", transition.name(), transition) - return transition.reversed(), False - return None, False - - def travel(composites, previous=None): - """ return a list of pairs of composites along all possible transitions - between all given composites by walking the tree of all combinations - """ - # if there is only one composite - if len(composites) == 1: - # transition to itself - return [composites[0], composites[0]] - # if call is not from recursion - if not previous: - # insert random first station - return Transitions.travel(composites, composites[0:1]) - # if maximum length has been reached - if len(previous) == len(composites) * len(composites) + 1: - # return ready sequence - return previous - # for all composites - for a in composites: - # check if we haven't had that combination previously - if not is_in(previous, [previous[-1], a]): - # try that combination - r = Transitions.travel(composites, previous + [a]) - # return result if we are ready here - if r: - return r - # no findings - return None - -class Transition: - - def __init__(self, name, a=None, b=None): - assert type(name) is str - self._name = name - if a: - # no overloaded constructors available in python m( - if b: - # got lists of frames in a and b with same length? - assert len(a) == len(b) - assert type(a[0]) is Frame - assert type(b[0]) is Frame - # rearrange composites - self.composites = [Composite("...", a[i], b[i]) - for i in range(len(a))] - else: - # if we got only one list then it must be composites - assert type(a[0]) is Composite - self.composites = a - else: - self.composites = [] - self.flip = None - - def __str__(self): - def hidden( x, hidden ): - return str(x).replace(' ','_') if hidden else str(x) - - # remember index when to flip sources A/B - result = "\t%s = %s -> %s:\n" % (self.name(), - self.begin().name, self.end().name) - # add table title - result += "\tNo. %s\n" % Composite.str_title() - # add composites until flipping point - for i in range(self.frames()): - if (not logKeyFramesOnly) or self.A(i).key: - result += (("\t%3d %s " + ("B%s\tA%s" if self.flip and i >= self.flip else "A%s\tB%s") + " %s\n") % - (i, " * " if self.A(i).key else " ", - hidden(self.A(i), self.A(i).invisible() or self.composites[i].covered()), - hidden(self.B(i), self.B(i).invisible()), - self.composites[i].name)) - return result - - def phi(self): - return self.begin().equals(self.end().swapped(), False) - - def name(self): - if self.phi(): - return "Φ(" + self._name + ")" - else: - return self._name - - def append(self, composite): - assert type(composite) == Composite - self.composites.append(composite) - - def frames(self): return len(self.composites) - - def A(self, n=None): - if n is None: - return [c.A() for c in self.composites] - else: - assert type(n) is int - return self.composites[n].A() - - def B(self, n=None): - if n is None: - return [c.B() for c in self.composites] - else: - assert type(n) is int - return self.composites[n].B() - - def Az(self, z0, z1): - frames = [] - for i,c in enumerate(self.composites): - if (not self.flip) or i < self.flip: - frames.append(c.Az(z0)) - else: - frames.append(c.Az(z1)) - return frames - - def Bz(self, z0, z1): - frames = [] - for i,c in enumerate(self.composites): - if (not self.flip) or i < self.flip: - frames.append(c.Bz(z0)) - else: - frames.append(c.Bz(z1)) - return frames - - def begin(self): return self.composites[0] - - def end(self): return self.composites[-1] - - def reversed(self): - return Transition(self._name + "⁻¹", self.composites[::-1]) - - def swapped(self): - return Transition(swap_name(self._name), [c.swapped() for c in self.composites]) - - def calculate_flip(self): - """ find the first non overlapping rectangle pair within parameters and - return it's index - """ - # check if a phi was applied - if self.phi(): - - # check if rectangle a and b overlap - def overlap(a, b): - return (a[L] < b[R] and a[R] > b[L] and a[T] < b[B] and a[B] > b[T]) - - # find the first non overlapping composite - for i in range(self.frames() - 2): - if not overlap(self.A(i).cropped(), self.B(i).cropped()): - return i - # at last we need to swap at the end - return self.frames() - 1 - # no flipping - return None - - def calculate(self, frames, a_corner=(R, T), b_corner=(L, T)): - """ calculate a transition between the given composites which shall - have the given amount of frames. Use a_corner of frames in A and - b_corner of frames in B to interpolate the animation movement. - """ - if len(self.composites) != frames: - num_keys = len(self.keys()) - if len(self.composites) != num_keys: - log.warning("Recalculating transition %s" % self.name()) - self.composites = self.keys() - # calculate that transition and place it into the dictonary - log.debug("Calculating transition %s\t= %s \t(%s key frames)" % - (self.name(), - " / ".join([c.name for c in self.composites]), - num_keys)) - - # extract two lists of frames for use with interpolate() - a = [c.A() for c in self.composites] - b = [c.B() for c in self.composites] - # check if begin and end of animation are equal - if a[-1] == a[0] and b[-1] == b[0]: - # then swap the end composite - a[-1], b[-1] = b[-1], a[-1] - # generate animation - a = interpolate(a, frames, a_corner) - b = interpolate(b, frames, b_corner) - composites = [] - j = 0 - for i in range(len(a)): - if a[i].key: - name = self.composites[j].name - j += 1 - else: - name = "..." - composites.append(Composite(len(composites), name, a[i], b[i])) - self.composites = composites - self.flip = self.calculate_flip() - - def keys(self): - """ return the indices of all key composites - """ - return [i for i in self.composites if i.key()] - - -def parse_asterisk(sequence, composites): - """ parses a string like '*/*' and returns all available variants with '*' - being replaced by composite names in 'composites'. - """ - sequences = [] - for k in range(len(sequence)): - if sequence[k] == '*': - for c in composites: - sequences += parse_asterisk(sequence[: k] + - [c.name] + sequence[k + 1:], - composites) - if not sequences: - sequences.append(sequence) - return sequences - - -def frange(x, y, jump): - """ like range() but for floating point values - """ - while x < y: - yield x - x += jump - - -def bspline(points): - """ do a B - Spline interpolation between the given points - returns interpolated points - """ - # parameter check - assert type(points) is np.ndarray - assert type(points[0]) is np.ndarray and len(points[0]) == 2 - assert type(points[1]) is np.ndarray and len(points[1]) == 2 - resolution = Transitions.resolution - # check if we have more than two points - if len(points) > 2: - # do interpolation - tck, u = spi.splprep(points.transpose(), s=0, k=2) - unew = np.arange(0, 1.0 + resolution, resolution) - return spi.splev(unew, tck) - elif len(points) == 2: - # throw points on direct line - x, y = [], [] - for i in frange(0.0, 1.0 + resolution, resolution): - x.append(points[0][X] + (points[1][X] - points[0][X]) * i) - y.append(points[0][Y] + (points[1][Y] - points[0][Y]) * i) - return [np.array(x), np.array(y)] - else: - return None - - -def find_nearest(spline, points): - """ find indices in spline which are most near to the coordinates in points - """ - nearest = [] - for p in points: - # calculation lamba fn - distance = (spline[X] - p[X])**2 + (spline[Y] - p[Y])**2 - # get index of point with the minimum distance - idx = np.where(distance == distance.min()) - nearest.append(idx[0][0]) - # return nearest points - return nearest - - -def measure(points): - """ measure distances between every given 2D point and the first point - """ - positions = [(0, 0, 0)] - # enumerate between all points - for i in range(1, len(points)): - # calculate X/Y distances - dx = points[i][X] - points[i - 1][X] - dy = points[i][Y] - points[i - 1][Y] - # calculate movement speed V - dv = math.sqrt(dx**2 + dy**2) - # sum up to last position - dx = positions[-1][X] + abs(dx) - dy = positions[-1][Y] + abs(dy) - dv = positions[-1][V] + dv - # append to result - positions.append((dx, dy, dv)) - # return array of distances - return positions - - -def smooth(x): - """ smooth value x by using a cosinus wave (0.0 <= x <= 1.0) - """ - return (-math.cos(math.pi * x) + 1) / 2 - - -def distribute(points, positions, begin, end, x0, x1, n): - """ from the sub set given by [:+1] selects points - whose distances are smoothly distributed and returns them. - holds a list of distances between all that will - be used for smoothing the distribution. - """ - assert type(points) is np.ndarray - assert type(positions) is list - assert type(begin) is np.int64 - assert type(end) is np.int64 - assert type(x0) is float - assert type(x1) is float - assert type(n) is int - # calculate overall distance from begin to end - length = positions[end - 1][V] - positions[begin][V] - # begin result with the first point - result = [] - # check if there is no movement - if length == 0.0: - for i in range(0, n): - result.append(points[begin]) - else: - # calculate start points - pos0 = smooth(x0) - pos1 = smooth(x1) - for i in range(0, n): - # calculate current x - x = smooth(x0 + ((x1 - x0) / n) * i) - # calculate distance on curve from y0 to y - pos = (x - pos0) / (pos1 - pos0) * length + positions[begin][V] - # find point with that distance - for j in range(begin, end): - if positions[j][V] >= pos: - # append point to result - result.append(points[j]) - break - # return result distribution - return result - - -def fade(begin, end, factor): - """ return value within begin and end at < factor > (0.0..1.0) - """ - # check if we got a bunch of values to morph - if type(begin) in [list, tuple]: - result = [] - # call fade() for every of these values - for i in range(len(begin)): - result.append(fade(begin[i], end[i], factor)) - else: - # return the resulting float - result = begin + (end - begin) * factor - return result - -def morph(begin, end, pt, corner, factor): - """ interpolates a new frame between two given frames 'begin and 'end' - putting the given 'corner' of the new frame's rectangle to point 'pt'. - 'factor' is the position bewteen begin (0.0) and end (1.0). - """ - result = Frame() - # calculate current size - size = fade(begin.size(), end.size(), factor) - # calculate current rectangle - result.rect = [pt[X] if corner[X] is L else int(round(pt[X] - size[X])), - pt[Y] if corner[Y] is T else int(round(pt[Y] - size[Y])), - pt[X] if corner[X] is R else int(round(pt[X] + size[X])), - pt[Y] if corner[Y] is B else int(round(pt[Y] + size[Y])), - ] - # calculate current alpha value and cropping - result.alpha = int(round(fade(begin.alpha, end.alpha, factor))) - result.crop = [int(round(x)) for x in fade(begin.crop, end.crop, factor)] - # copy orignial size from begin - result.original_size = begin.original_size - return result - -def interpolate(key_frames, num_frames, corner): - """ interpolate < num_frames > points of one corner defined by < corner > - between the rectangles given by < key_frames > - """ - # get corner points defined by index_x,index_y from rectangles - corners = np.array([i.corner(corner[X], corner[Y]) for i in key_frames]) - # interpolate between corners and get the spline points and the indexes of - # those which are the nearest to the corner points - spline = bspline(corners) - # skip if we got no interpolation - if not spline: - return [], [] - # find indices of the corner's nearest points within the spline - corner_indices = find_nearest(spline, corners) - # transpose point array - spline = np.transpose(spline) - # calulcate number of frames between every corner - num_frames_per_move = int(round(num_frames / (len(corner_indices) - 1))) - # measure the spline - positions = measure(spline) - # fill with point animation from corner to corner - animation = [] - for i in range(1, len(corner_indices)): - # substitute indices of corner pair - begin = corner_indices[i - 1] - end = corner_indices[i] - # calculate range of X between 0.0 and 1.0 for these corners - _x0 = (i - 1) / (len(corner_indices) - 1) - _x1 = i / (len(corner_indices) - 1) - # create distribution of points between these corners - corner_animation = distribute( - spline, positions, begin, end, _x0, _x1, num_frames_per_move - 1) - # append first rectangle from parameters - animation.append(key_frames[i - 1]) - # cound index - for j in range(len(corner_animation)): - # calculate current sinus wave acceleration - frame = morph(key_frames[i - 1], key_frames[i], - corner_animation[j], corner, - smooth(j / len(corner_animation))) - # append to resulting animation - animation.append(frame) - # append last rectangle from parameters - animation.append(key_frames[-1]) - # return rectangle animation - return animation - - -def is_in(sequence, part): - """ returns true if 2-item list 'part' is in list 'sequence' - """ - assert len(part) == 2 - for i in range(0, len(sequence) - 1): - if sequence[i: i + 2] == part: - return True - return False - -def fade_alpha(frame,alpha,frames): - result = [] - for i in range(0,frames): - f = frame.duplicate() - f.alpha = fade(frame.alpha,alpha,smooth(float(i)/frames)) - result.append(f) - return result diff -Nru voctomix-1.3+git20200101/voctocore/default-config.ini voctomix-1.3+git20200102/voctocore/default-config.ini --- voctomix-1.3+git20200101/voctocore/default-config.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/default-config.ini 2020-01-03 00:02:24.000000000 +0000 @@ -1,14 +1,130 @@ [mix] -sources = CAM1,CAM2,LAPTOP +videocaps = video/x-raw,format=I420,width=1920,height=1080,framerate=25/1,pixel-aspect-ratio=1/1,interlace-mode=progressive +audiocaps = audio/x-raw,format=S16LE,channels=2,layout=interleaved,rate=48000 + +; tcp-ports will be 10000,10001,10002 +sources = cam1,cam2,grabber + +; setting this will create another stream-blanker which can be used to stream slides with the blanker-feature +;slides_source_name=grabber + +; number of stereo-streams used in the mix. the exact same number needs to be supplied from each +; source and is passed to each sink +audiostreams = 1 + +; set the initial audio source (shortcut for setting the volume of the +; audio-sources to 1.0), defaults to the first source +;audiosource = cam1 + +[source.cam1] +;deinterlace = yes +;deinterlace = no +deinterlace = assume-progressive +;kind = decklink +;devicenumber = 0 +;video_connection = SDI +;video_mode = 1080i50 +;video_format = auto +;audio_connection = embedded +;volume=0.5 + +;audiostream[0] = 0+1 +;audiostream[1] = 2 +;audiostream[2] = 3 + + +[source.cam2] +;deinterlace = yes +;deinterlace = no +deinterlace = assume-progressive +;kind = tcp +;volume = 0.5 + +[source.grabber] +deinterlace = assume-progressive + +;[source.background] +;kind = img +;imguri = file:///opt/voc/share/background.png + + +[output-buffers] +; voctocore has a buffer on all video-outputs, that store video-frames for your +; sink when it can't handle them all in real-time. so if your sink takes 2 seconds +; to process a really hard to precess frame, voctomix needs to store 50 frames +; for you, hoping that your sink will catch up soon. +; if the sink doesn ot catch up in time, voctomix will drop it and remove it +; from the output. it's your task to restart it in such a situation. +; by default, voctomix will store up to 500 frames for your sink (20 seconds) +; you might want to up that even more for your recording-sink, so that it never +; gets disconnected. for this reason, the following configuration raises the +; default limit for the mix_out sink to a whopping 10'000 frames (400 seconds) +;cam1_mirror = 500 +;cam2_mirror = 500 +;grabber_mirror = 500 +mix_out = 10000 +;streamblanker_out = 500 + +[fullscreen] +; if configured, switching to fullscreen will automatically select this +; source. if not configured, it will not change the last set source +;default-a = cam1 + +[side-by-side-equal] +; defaults to 1% of the video width +;border = 0 +;gutter = 12 +;atop = 50 +;btop = 200 + +; if configured, switching to the sbs-equal mode will automatically select these +; sources. if not configured, it will not change the last set sources +;default-a = cam1 +;default-b = cam2 + +[side-by-side-preview] +;asize = 1024x576 +;acrop=0/0/0/0 +;apos = 12/12 +;bsize = 320x180 +;bcrop=0/640/0/640 +;bpos = 948/528 + +; automatically select these sources when switching to sbs-preview +;default-a = grabber +;default-b = cam1 + +[picture-in-picture] +;pipsize = 320x180 +;pipcrop=0/600/0/600 +;pippos = 948/528 + +; automatically select these sources when switching to pip +;default-a = grabber +;default-b = cam1 [previews] -; enable previews so we can see something in VOC2GUI +; disable if ui & server run on the same computer and can exchange uncompressed video frames +enabled = false +deinterlace = false + +; use vaapi to encode the previews, can be h264, mpeg2 or jpeg (BUT ONLY h264 IS TESTED) +; not all encoders are available on all CPUs +;vaapi = h264 + +; default to mix-videocaps, only applicable if enabled=true +; you can change the framerate and the width/height, but nothing else +;videocaps = video/x-raw,width=1024,height=576,framerate=25/1 + +[stream-blanker] enabled = true +sources = pause,nostream +volume = 1.0 + +;[source.stream-blanker-pause] +;kind=img +;imguri=file:///home/peter/VOC/voctomix/bgloop.jpg -[composites] -; fullscreen source B is full transparent -FULL.alpha-b = 0 - -[transitions] -; unique name = ms, from / [... /] to -FADE = 750, FULL / FULL +[mirrors] +; disable if not needed +enabled = true diff -Nru voctomix-1.3+git20200101/voctocore/images/pipelines.graphml voctomix-1.3+git20200102/voctocore/images/pipelines.graphml --- voctomix-1.3+git20200101/voctocore/images/pipelines.graphml 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/images/pipelines.graphml 1970-01-01 00:00:00.000000000 +0000 @@ -1,1318 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mix -Compositor - - - - - - - - - - - Audio -Mixer - - - - - - - - - - - DeMux - - - - - - - - - - - Mix -Blinding -Compositor - - - - - - - - - - - Background -Video Source -:16000 - - - - - - - - - - - Audio -Blinding -Mixer - - - - - - - - - - - Mux - - - - - - - - - - - Mix -Live -:15000 - - - - - - - - - - - Mix -Preview -:11100 - - - - - - - - - - - Mux - - - - - - - - - - - Blinding -Audio Source -:18000 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sources -Recording -:13000... - - - - - - - - - - - Mix -Recording -:11000 - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Input - - - - - - - - - - - Output - - - - - - - - - - - Scale - - - - - - - - - - - Sources -Blinding -Compositor - - - - - - - - - - - live source? - - - - - - - - - - - filter - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Scale - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sources -Live -:15001... - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sources -Preview -:13100... - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sources -:10000... - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Overlay -Sources -(local files) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Blinding -Video Sources -:17000... - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - A/V - - - - - - - - - - - Video - - - - - - - - - - - Audio - - - - - - - - - - - Image - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctocore/images/pipelines.svg voctomix-1.3+git20200102/voctocore/images/pipelines.svg --- voctomix-1.3+git20200101/voctocore/images/pipelines.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/images/pipelines.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,2226 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mix - Compositor - - - - - - - Audio - Mixer - - - - - - - - - DeMux - - - - - - - Mix - Blinding - Compositor - - - - - - - Background - Video Source - :16000 - - - - - - - Audio - Blinding - Mixer - - - - - - - Mux - - - - - - - Mix - Live - :15000 - - - - - - - Mix - Preview - :11100 - - - - - - - Mux - - - - - - - Blinding - Audio Source - :18000 - - - - - - - - - - - - - - - - - - - Sources - Recording - :13000... - - - - - - - Mix - Recording - :11000 - - - - - - - Mux - - - - - - - - - - - - - - - - - - - Mux - - - - - - - Input - - - - - - - Output - - - - - - - Scale - - - - - - - Sources - Blinding - Compositor - - - - - - - live source? - - - - - - - filter - - - - - - - - - - - - - Scale - - - - - - - - - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - Mux - - - - - - - - - - - - - - - - - - - Sources - Live - :15001... - - - - - - - - - - - - - - - - - - - Sources - Preview - :13100... - - - - - - - - - - - - - - - - - - - Sources - :10000... - - - - - - - - - - - - - - - - - - - Overlay - Sources - (local files) - - - - - - - - - - - - - - - - - - - Blinding - Video Sources - :17000... - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - A/V - - - Video - - - Audio - - - Image - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/voctocore/images/voc2.xcf and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/voctocore/images/voc2.xcf differ diff -Nru voctomix-1.3+git20200101/voctocore/lib/args.py voctomix-1.3+git20200102/voctocore/lib/args.py --- voctomix-1.3+git20200101/voctocore/lib/args.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/args.py 2020-01-03 00:02:24.000000000 +0000 @@ -10,7 +10,7 @@ parser = argparse.ArgumentParser(description='Voctocore') parser.add_argument('-v', '--verbose', action='count', default=0, - help="Set verbosity level by using -v, -vv or -vvv.") + help="Also print INFO and DEBUG messages.") parser.add_argument('-c', '--color', action='store', @@ -24,19 +24,4 @@ parser.add_argument('-i', '--ini-file', action='store', help="Load a custom config.ini-File") - parser.add_argument('-p', '--pipeline', action='store_true', - help="Generate text files of pipelines") - - parser.add_argument('-n', '--no-bins', action='store_true', - help="Do not use gstreamer bins") - - parser.add_argument('-d', '--dot', action='store_true', - help="Generate DOT files of pipelines into directory given in environment variable GST_DEBUG_DUMP_DOT_DIR") - - parser.add_argument('-D', '--gst-debug-details', action='store', default=1, - help="Set details in dot graph. GST_DEBUG_DETAILS must be a combination the following values: 1 = show caps-name on edges, 2 = show caps-details on edges, 4 = show modified parameters on elements, 8 = show element states, 16 = show full element parameter values even if they are very long. Default: 15 = show all the typical details that one might want (15=1+2+4+8)") - - parser.add_argument('-g', '--gstreamer-log', action='count', default=0, - help="Log gstreamer messages into voctocore log (Set log level by using -g, -gg or -ggg).") - Args = parser.parse_args() diff -Nru voctomix-1.3+git20200101/voctocore/lib/audiomix.py voctomix-1.3+git20200102/voctocore/lib/audiomix.py --- voctomix-1.3+git20200101/voctocore/lib/audiomix.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/audiomix.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,110 +1,155 @@ -#!/usr/bin/env python3 import logging from configparser import NoOptionError, NoSectionError +from gi.repository import Gst + from lib.config import Config +from lib.clock import Clock from lib.errors.configuration_error import ConfigurationError -from lib.args import Args class AudioMix(object): - def __init__(self): self.log = logging.getLogger('AudioMix') - self.audio_streams = Config.getAudioStreams() - self.streams = self.audio_streams.get_stream_names() - # initialize all sources to silent - self.volumes = [1.0] * len(self.streams) + self.caps = Config.get('mix', 'audiocaps') + self.names = Config.getlist('mix', 'sources') + self.log.info('Configuring Mixer for %u Sources', len(self.names)) - self.log.info('Configuring audio mixer for %u streams', - len(self.streams)) + # initialize all sources to silent + self.volumes = [0.0] * len(self.names) + is_configured = False - self.mix_volume = 1.0 + # try per-source volume-setting + for index, name in enumerate(self.names): + section = 'source.{}'.format(name) + try: + volume = Config.getfloat(section, 'volume') + self.log.info('Setting Volume of Source %s to %0.2f', + name, volume) + self.volumes[index] = volume + is_configured = True + except (NoSectionError, NoOptionError): + pass + + # try [mix]audiosource shortcut + try: + name = Config.get('mix', 'audiosource') + if is_configured: + raise ConfigurationError( + 'cannot configure [mix]audiosource-shortcut and ' + '[source.*]volume at the same time') + + if name not in self.names: + raise ConfigurationError( + 'unknown source configured as [mix]audiosource: %s', name) + + index = self.names.index(name) + self.log.info('Setting Volume of Source %s to %0.2f', name, 1.0) + self.volumes[index] = 1.0 + is_configured = True + except NoOptionError: + pass + + if is_configured: + self.log.info( + 'Volume was configured, advising ui not to show a selector') + Config.add_section_if_missing('audio') + Config.set('audio', 'volumecontrol', 'false') - self.bin = "" if Args.no_bins else """ - bin.( - name=AudioMix - """ - - channels = Config.getAudioChannels() - - def identity(): - matrix = [[0.0 for x in range(0, channels)] - for x in range(0, channels)] - for i in range(0, channels): - matrix[i][i] = 1.0 - return str(matrix).replace("[","<").replace("]",">") - - self.bin += """ - audiomixer - name=audiomixer - ! queue - max-size-time=3000000000 - name=queue-audiomixer-audiomixmatrix - ! audiomixmatrix - name=audiomixer-audiomixmatrix - in_channels={in_channels} - out_channels={out_channels} - matrix="{matrix}" - ! queue - max-size-time=3000000000 - name=queue-audio-mix - ! tee - name=audio-mix - """.format(in_channels=channels, - out_channels=channels, - matrix=identity()) - - for stream in self.streams: - self.bin += """ - audio-{stream}. - ! queue - max-size-time=3000000000 - name=queue-audio-{stream} - ! audiomixer. - """.format(stream=stream) - self.bin += "" if Args.no_bins else "\n)" + else: + self.log.info('Setting Volume of first Source %s to %0.2f', + self.names[0], 1.0) + self.volumes[0] = 1.0 + + pipeline = "" + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + pipeline += """ + audiomixer name=mix_{audiostream} ! + {caps} ! + queue ! + tee name=tee_{audiostream} + + tee_{audiostream}. ! queue ! interaudiosink + channel=audio_mix_out_stream{audiostream} + """.format( + caps=self.caps, + audiostream=audiostream, + ) + + if Config.getboolean('previews', 'enabled'): + pipeline += """ + tee_{audiostream}. ! queue ! interaudiosink + channel=audio_mix_preview_stream{audiostream} + """.format( + audiostream=audiostream, + ) + + if Config.getboolean('stream-blanker', 'enabled'): + pipeline += """ + tee_{audiostream}. ! queue ! interaudiosink + channel=audio_mix_stream{audiostream}_stream-blanker + """.format( + audiostream=audiostream, + ) + + for idx, name in enumerate(self.names): + pipeline += """ + interaudiosrc + channel=audio_{name}_mixer_stream{audiostream} ! + {caps} ! + mix_{audiostream}. + """.format( + name=name, + caps=self.caps, + audiostream=audiostream, + ) + + self.log.debug('Creating Mixing-Pipeline:\n%s', pipeline) + self.mixingPipeline = Gst.parse_launch(pipeline) + self.mixingPipeline.use_clock(Clock) + + self.log.debug('Binding Error & End-of-Stream-Signal ' + 'on Mixing-Pipeline') + self.mixingPipeline.bus.add_signal_watch() + self.mixingPipeline.bus.connect("message::eos", self.on_eos) + self.mixingPipeline.bus.connect("message::error", self.on_error) - def attach(self, pipeline): - self.pipeline = pipeline + self.log.debug('Initializing Mixer-State') self.updateMixerState() - def __str__(self): - return 'AudioMix' - - def isConfigured(self): - for v in self.volumes: - if v > 0.0: - return True - return False + self.log.debug('Launching Mixing-Pipeline') + self.mixingPipeline.set_state(Gst.State.PLAYING) def updateMixerState(self): - self.log.info('Updating mixer state') + self.log.info('Updating Mixer-State') - for idx, name in enumerate(self.streams): - volume = self.volumes[idx] * self.mix_volume + for idx, name in enumerate(self.names): + volume = self.volumes[idx] - self.log.debug('Setting stream %s to volume=%0.2f', name, volume) - mixer = self.pipeline.get_by_name('audiomixer') - mixerpad = mixer.get_static_pad('sink_%d' % idx) - mixerpad.set_property('volume', volume) + self.log.debug('Setting Mixerpad %u to volume=%0.2f', idx, volume) + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + mixer = self.mixingPipeline.get_by_name( + 'mix_{}'.format(audiostream)) - def setAudioSource(self, source): - self.volumes = [float(idx == source) - for idx in range(len(self.sources))] - self.updateMixerState() + mixerpad = mixer.get_static_pad('sink_%u' % idx) + mixerpad.set_property('volume', volume) - def setAudioSourceVolume(self, stream, volume): - if stream == 'mix': - self.mix_volume = volume - else: - self.volumes[stream] = volume + def setAudioSource(self, source): + self.volumes = [float(idx == source) for idx in range(len(self.names))] self.updateMixerState() - def setAudioVolume(self, volume): - self.mix_volume = volume + def setAudioSourceVolume(self, source, volume): + self.volumes[source] = volume self.updateMixerState() def getAudioVolumes(self): return self.volumes + + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Mixing-Pipeline') + + def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Mixing-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) diff -Nru voctomix-1.3+git20200101/voctocore/lib/avpreviewoutput.py voctomix-1.3+git20200102/voctocore/lib/avpreviewoutput.py --- voctomix-1.3+git20200101/voctocore/lib/avpreviewoutput.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/avpreviewoutput.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,105 +1,83 @@ -#!/usr/bin/env python3 -from lib.tcpmulticonnection import TCPMultiConnection -from lib.config import Config -from lib.args import Args -from gi.repository import Gst import logging -import gi -gi.require_version('GstController', '1.0') +from gi.repository import Gst -class AVPreviewOutput(TCPMultiConnection): +from lib.config import Config +from lib.tcpmulticonnection import TCPMultiConnection +from lib.clock import Clock - def __init__(self, source, port, use_audio_mix=False, audio_blinded=False): - # create logging interface - self.log = logging.getLogger('AVPreviewOutput[{}]'.format(source)) - # initialize super +class AVPreviewOutput(TCPMultiConnection): + def __init__(self, channel, port): + self.log = logging.getLogger('AVPreviewOutput[{}]'.format(channel)) super().__init__(port) - # remember things - self.source = source + self.channel = channel - # open bin - self.bin = "" if Args.no_bins else """ - bin.( - name=AVPreviewOutput-{source} - """.format(source=self.source) - - # video pipeline - self.bin += """ - video-{source}. - ! {vcaps} - ! queue - max-size-time=3000000000 - name=queue-preview-video-{source} - {vpipeline} - ! queue - max-size-time=3000000000 - name=queue-mux-preview-{source} - ! mux-preview-{source}. - """.format(source=self.source, - vpipeline=self.construct_video_pipeline(), - vcaps=Config.getVideoCaps() - ) - - # audio pipeline - if use_audio_mix or source in Config.getAudioSources(internal=True): - self.bin += """ - {use_audio}audio-{audio_source}{audio_blinded}. - ! queue - max-size-time=3000000000 - name=queue-preview-audio-{source} - ! audioconvert - ! queue - max-size-time=3000000000 - name=queue-mux-preview-audio-{source} - ! mux-preview-{source}. - """.format(source=self.source, - use_audio="" if use_audio_mix else "source-", - audio_source="mix" if use_audio_mix else self.source, - audio_blinded="-blinded" if audio_blinded else "" - ) - - # playout pipeline - self.bin += """ - matroskamux - name=mux-preview-{source} - streamable=true - writing-app=Voctomix-AVPreviewOutput - ! queue - max-size-time=3000000000 - name=queue-fd-preview-{source} - ! multifdsink - blocksize=1048576 - buffers-max=500 - sync-method=next-keyframe - name=fd-preview-{source} - """.format(source=self.source) - - # close bin - self.bin += "" if Args.no_bins else "\n)\n" - - def audio_channels(self): - return Config.getNumAudioStreams() - - def video_channels(self): - return 1 - - def is_input(self): - return False - - def __str__(self): - return 'AVPreviewOutput[{}]'.format(self.source) - - def construct_video_pipeline(self): - if Config.getPreviewVaapi(): - return self.construct_vaapi_video_pipeline() + if Config.has_option('previews', 'videocaps'): + target_caps = Config.get('previews', 'videocaps') else: - return self.construct_native_video_pipeline() + target_caps = Config.get('mix', 'videocaps') - def construct_vaapi_video_pipeline(self): - # https://blogs.igalia.com/vjaquez/2016/04/06/gstreamer-vaapi-1-8-the-codec-split/ + pipeline = """ + intervideosrc channel=video_{channel} ! + {vcaps} ! + {vpipeline} ! + queue ! + mux. + """.format( + channel=self.channel, + vcaps=Config.get('mix', 'videocaps'), + vpipeline=self.construct_video_pipeline(target_caps) + ) + + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + pipeline += """ + interaudiosrc channel=audio_{channel}_stream{audiostream} ! + {acaps} ! + queue ! + mux. + """.format( + channel=self.channel, + acaps=Config.get('mix', 'audiocaps'), + audiostream=audiostream, + ) + + pipeline += """ + matroskamux + name=mux + streamable=true + writing-app=Voctomix-AVPreviewOutput ! + + multifdsink + blocksize=1048576 + buffers-max=500 + sync-method=next-keyframe + name=fd + """ + + self.log.debug('Creating Output-Pipeline:\n%s', pipeline) + self.outputPipeline = Gst.parse_launch(pipeline) + self.outputPipeline.use_clock(Clock) + + self.log.debug('Binding Error & End-of-Stream-Signal ' + 'on Output-Pipeline') + self.outputPipeline.bus.add_signal_watch() + self.outputPipeline.bus.connect("message::eos", self.on_eos) + self.outputPipeline.bus.connect("message::error", self.on_error) + + self.log.debug('Launching Output-Pipeline') + self.outputPipeline.set_state(Gst.State.PLAYING) + + def construct_video_pipeline(self, target_caps): + vaapi_enabled = Config.has_option('previews', 'vaapi') + if vaapi_enabled: + return self.construct_vaapi_video_pipeline(target_caps) + + else: + return self.construct_native_video_pipeline(target_caps) + + def construct_vaapi_video_pipeline(self, target_caps): if Gst.version() < (1, 8): vaapi_encoders = { 'h264': 'vaapiencode_h264', @@ -114,78 +92,81 @@ } vaapi_encoder_options = { - 'h264': """rate-control=cqp - init-qp=10 - max-bframes=0 - keyframe-period=60""", - 'jpeg': """quality=90 - keyframe-period=0""", - 'mpeg2': "keyframe-period=60", + 'h264': 'rate-control=cqp init-qp=10 ' + 'max-bframes=0 keyframe-period=60', + 'jpeg': 'vaapiencode_jpeg quality=90' + 'keyframe-period=0', + 'mpeg2': 'keyframe-period=60', } - # prepare selectors - size = Config.getPreviewResolution() - framerate = Config.getPreviewFramerate() - vaapi = Config.getPreviewVaapi() - denoise = Config.getDenoiseVaapi() - scale_method = Config.getScaleMethodVaapi() - - # generate pipeline - # we can also force a video format here (format=I420) but this breaks scalling at least on Intel HD3000 therefore it currently removed - return """ ! capsfilter - caps=video/x-raw,interlace-mode=progressive - ! vaapipostproc - ! video/x-raw,width={width},height={height},framerate={n}/{d},deinterlace-mode={imode},deinterlace-method=motion-adaptive,denoise={denoise},scale-method={scale_method} - ! {encoder} - {options}""".format(imode='interlaced' if Config.getDeinterlacePreviews() else 'disabled', - width=size[0], - height=size[1], - encoder=vaapi_encoders[vaapi], - options=vaapi_encoder_options[vaapi], - n=framerate[0], - d=framerate[1], - denoise=denoise, - scale_method=scale_method, - ) - - def construct_native_video_pipeline(self): - # maybe add deinterlacer - if Config.getDeinterlacePreviews(): - pipeline = """ ! deinterlace - mode=interlaced - """ - else: - pipeline = "" + encoder = Config.get('previews', 'vaapi') + do_deinterlace = Config.getboolean('previews', 'deinterlace') - # build rest of the pipeline - pipeline += """ ! videorate - ! videoscale - ! {vcaps} - ! jpegenc - quality = 90""".format(vcaps=Config.getPreviewCaps()) - return pipeline + caps = Gst.Caps.from_string(target_caps) + struct = caps.get_structure(0) + _, width = struct.get_int('width') + _, height = struct.get_int('height') + (_, framerate_numerator, + framerate_denominator) = struct.get_fraction('framerate') + + return ''' + capsfilter caps=video/x-raw,interlace-mode=progressive ! + vaapipostproc + format=i420 + deinterlace-mode={imode} + deinterlace-method=motion-adaptive + width={width} + height={height} ! + capssetter caps=video/x-raw,framerate={n}/{d} ! + {encoder} {options} + '''.format( + imode='interlaced' if do_deinterlace else 'disabled', + width=width, + height=height, + encoder=vaapi_encoders[encoder], + options=vaapi_encoder_options[encoder], + n=framerate_numerator, + d=framerate_denominator, + ) + + def construct_native_video_pipeline(self, target_caps): + do_deinterlace = Config.getboolean('previews', 'deinterlace') + + if do_deinterlace: + pipeline = ''' + deinterlace mode={imode} ! + videorate ! + ''' + else: + pipeline = '' - def attach(self, pipeline): - self.pipeline = pipeline + pipeline += ''' + videoscale ! + {target_caps} ! + jpegenc quality=90 + ''' + + return pipeline.format( + imode='interlaced' if do_deinterlace else 'disabled', + target_caps=target_caps, + ) def on_accepted(self, conn, addr): self.log.debug('Adding fd %u to multifdsink', conn.fileno()) - - # find fdsink and emit 'add' - fdsink = self.pipeline.get_by_name("fd-preview-{}".format(self.source)) + fdsink = self.outputPipeline.get_by_name('fd') fdsink.emit('add', conn.fileno()) - # catch disconnect def on_disconnect(multifdsink, fileno): if fileno == conn.fileno(): self.log.debug('fd %u removed from multifdsink', fileno) self.close_connection(conn) + fdsink.connect('client-fd-removed', on_disconnect) - # catch client-removed - def on_client_removed(multifdsink, fileno, status): - # GST_CLIENT_STATUS_SLOW = 3, - if fileno == conn.fileno() and status == 3: - self.log.warning('about to remove fd %u from multifdsink ' - 'because it is too slow!', fileno) - fdsink.connect('client-removed', on_client_removed) + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Output-Pipeline') + + def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Output-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) diff -Nru voctomix-1.3+git20200101/voctocore/lib/avrawoutput.py voctomix-1.3+git20200102/voctocore/lib/avrawoutput.py --- voctomix-1.3+git20200101/voctocore/lib/avrawoutput.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/avrawoutput.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,114 +1,91 @@ -#!/usr/bin/env python3 import logging -from lib.args import Args +from gi.repository import Gst + from lib.config import Config from lib.tcpmulticonnection import TCPMultiConnection +from lib.clock import Clock class AVRawOutput(TCPMultiConnection): - - def __init__(self, source, port, use_audio_mix=False, audio_blinded=False): - # create logging interface - self.log = logging.getLogger('AVRawOutput[{}]'.format(source)) - - # initialize super + def __init__(self, channel, port): + self.log = logging.getLogger('AVRawOutput[{}]'.format(channel)) super().__init__(port) - # remember things - self.source = source + self.channel = channel - # open bin - self.bin = "" if Args.no_bins else """ - bin.( - name=AVRawOutput-{source} - """.format(source=self.source) - - # video pipeline - self.bin += """ - video-{source}. - ! {vcaps} - ! queue - max-size-time=3000000000 - name=queue-mux-video-{source} - ! mux-{source}. - """.format(source=self.source, - vcaps=Config.getVideoCaps()) - - # audio pipeline - if use_audio_mix or source in Config.getAudioSources(internal=True): - self.bin += """ - {use_audio}audio-{audio_source}{audio_blinded}. - ! queue - max-size-time=3000000000 - name=queue-audio-mix-convert-{source} - ! audioconvert - ! queue - max-size-time=3000000000 - name=queue-mux-audio-{source} - ! mux-{source}. - """.format( - source=self.source, - use_audio="" if use_audio_mix else "source-", - audio_source="mix" if use_audio_mix else self.source, - audio_blinded="-blinded" if audio_blinded else "" - ) - - # playout pipeline - self.bin += """ - matroskamux - name=mux-{source} - streamable=true - writing-app=Voctomix-AVRawOutput - ! queue - max-size-time=3000000000 - name=queue-fd-{source} - ! multifdsink - blocksize=1048576 - buffers-max={buffers_max} - sync-method=next-keyframe - name=fd-{source} - """.format( - buffers_max=Config.getOutputBuffers(self.source), - source=self.source + pipeline = """ + intervideosrc channel=video_{channel} ! + {vcaps} ! + queue ! + mux. + """.format( + channel=self.channel, + vcaps=Config.get('mix', 'videocaps'), ) - # close bin - self.bin += "" if Args.no_bins else "\n)\n" - - def audio_channels(self): - return Config.getNumAudioStreams() - - def video_channels(self): - return 1 - - def is_input(self): - return False + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + pipeline += """ + interaudiosrc channel=audio_{channel}_stream{audiostream} ! + {acaps} ! + queue ! + mux. + """.format( + channel=self.channel, + acaps=Config.get('mix', 'audiocaps'), + audiostream=audiostream, + ) - def __str__(self): - return 'AVRawOutput[{}]'.format(self.source) + pipeline += """ + matroskamux + name=mux + streamable=true + writing-app=Voctomix-AVRawOutput ! + + multifdsink + blocksize=1048576 + buffers-max={buffers_max} + sync-method=next-keyframe + name=fd + """.format( + buffers_max=Config.getint('output-buffers', channel, fallback=500) + ) + self.log.debug('Creating Output-Pipeline:\n%s', pipeline) + self.outputPipeline = Gst.parse_launch(pipeline) + self.outputPipeline.use_clock(Clock) + + self.log.debug('Binding Error & End-of-Stream-Signal ' + 'on Output-Pipeline') + self.outputPipeline.bus.add_signal_watch() + self.outputPipeline.bus.connect("message::eos", self.on_eos) + self.outputPipeline.bus.connect("message::error", self.on_error) - def attach(self, pipeline): - self.pipeline = pipeline + self.log.debug('Launching Output-Pipeline') + self.outputPipeline.set_state(Gst.State.PLAYING) def on_accepted(self, conn, addr): self.log.debug('Adding fd %u to multifdsink', conn.fileno()) - - # find fdsink and emit 'add' - fdsink = self.pipeline.get_by_name("fd-{}".format(self.source)) + fdsink = self.outputPipeline.get_by_name('fd') fdsink.emit('add', conn.fileno()) - # catch disconnect - def on_client_fd_removed(multifdsink, fileno): + def on_disconnect(multifdsink, fileno): if fileno == conn.fileno(): self.log.debug('fd %u removed from multifdsink', fileno) self.close_connection(conn) - fdsink.connect('client-fd-removed', on_client_fd_removed) - # catch client-removed - def on_client_removed(multifdsink, fileno, status): + def on_about_to_disconnect(multifdsink, fileno, status): # GST_CLIENT_STATUS_SLOW = 3, if fileno == conn.fileno() and status == 3: self.log.warning('about to remove fd %u from multifdsink ' 'because it is too slow!', fileno) - fdsink.connect('client-removed', on_client_removed) + + fdsink.connect('client-fd-removed', on_disconnect) + fdsink.connect('client-removed', on_about_to_disconnect) + + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Output-Pipeline') + + def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Output-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) diff -Nru voctomix-1.3+git20200101/voctocore/lib/blinder.py voctomix-1.3+git20200102/voctocore/lib/blinder.py --- voctomix-1.3+git20200101/voctocore/lib/blinder.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/blinder.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -import logging - -from gi.repository import Gst - -from lib.config import Config -from lib.clock import Clock -from lib.args import Args - - -class Blinder(object): - # create logging interface - log = logging.getLogger('Blinder') - - def __init__(self): - - # remember some things - self.acaps = Config.getAudioCaps() - self.vcaps = Config.getVideoCaps() - self.volume = Config.getBlinderVolume() - self.blindersources = Config.getBlinderSources() - - self.log.info('Configuring video blinders for %u sources', - len(self.blindersources)) - - # open bin - self.bin = "" if Args.no_bins else """ - bin.( - name=blinders - """ - - # list blinders - self.livesources = Config.getLiveSources() - - # add blinder pipelines - for livesource in self.livesources: - self.bin += """ - compositor - name=compositor-blinder-{livesource} - ! queue - max-size-time=3000000000 - name=queue-video-{livesource}-blinded - ! tee - name=video-{livesource}-blinded - - video-{livesource}. - ! queue - max-size-time=3000000000 - name=queue-video-{livesource}-compositor-blinder-{livesource} - ! compositor-blinder-{livesource}. - """.format(livesource=livesource) - - for blindersource in self.blindersources: - self.bin += """ - video-{blindersource}. - ! queue - max-size-time=3000000000 - name=queue-video-blinder-{blindersource}-compositor-blinder-{livesource} - ! compositor-blinder-{livesource}. - """.format( - blindersource=blindersource, - livesource=livesource - ) - - # Audiomixer - self.bin += """ - audiomixer - name=audiomixer-blinder - ! audioamplify - amplification={volume} - ! queue - name=queue-audio-mix-blinded - max-size-time=3000000000 - ! tee - name=audio-mix-blinded - - audio-mix. - ! queue - max-size-time=3000000000 - name=queue-capssetter-blinder - ! capssetter - caps={acaps} - ! queue - max-size-time=3000000000 - name=queue-audiomixer-blinder - ! audiomixer-blinder. - """.format(acaps=self.acaps, - volume=Config.getBlinderVolume() - ) - - # Source from the Blank-Audio-Tee into the Audiomixer - self.bin += """ - audio-blinder. - ! queue - max-size-time=3000000000 - name=queue-audio-blinded-audiomixer-blinder - ! audiomixer-blinder. - """ - - # close bin - self.bin += "" if Args.no_bins else "\n)\n" - - self.blind_source = 0 if len(self.blindersources) > 0 else None - - def __str__(self): - return 'Blinder' - - def attach(self, pipeline): - self.pipeline = pipeline - self.applyMixerState() - - def applyMixerState(self): - for livesource in self.livesources: - self.applyMixerStateVideo( - 'compositor-blinder-{}'.format(livesource)) - self.applyMixerStateVideo( - 'compositor-blinder-{}'.format(livesource)) - self.applyMixerStateAudio('audiomixer-blinder') - - def applyMixerStateVideo(self, mixername): - mixer = self.pipeline.get_by_name(mixername) - if not mixer: - self.log.error("Video mixer '%s' not found", mixername) - else: - mixer.get_static_pad('sink_0').set_property( - 'alpha', int(self.blind_source is None)) - for idx, name in enumerate(self.blindersources): - blinder_pad = mixer.get_static_pad('sink_%u' % (idx + 1)) - blinder_pad.set_property( - 'alpha', int(self.blind_source == idx)) - - def applyMixerStateAudio(self, mixername): - mixer = self.pipeline.get_by_name(mixername) - if not mixer: - self.log.error("Audio mixer '%s' not found", mixername) - else: - mixer.get_static_pad('sink_0').set_property( - 'volume', 1.0 if self.blind_source is None else 0.0) - mixer.get_static_pad('sink_1').set_property( - 'volume', 0.0 if self.blind_source is None else 1.0) - - def setBlindSource(self, source): - self.blind_source = source - self.applyMixerState() diff -Nru voctomix-1.3+git20200101/voctocore/lib/clock.py voctomix-1.3+git20200102/voctocore/lib/clock.py --- voctomix-1.3+git20200101/voctocore/lib/clock.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/clock.py 2020-01-03 00:02:24.000000000 +0000 @@ -9,7 +9,7 @@ log.debug("Obtaining System-Clock") Clock = Gst.SystemClock.obtain() -log.info("Using System-Clock for all pipelines.") +log.info("Using System-Clock for all Pipelines: %s", Clock) log.info("Starting NetTimeProvider on Port %u", port) NetTimeProvider = GstNet.NetTimeProvider.new(Clock, '::', port) diff -Nru voctomix-1.3+git20200101/voctocore/lib/commands.py voctomix-1.3+git20200102/voctocore/lib/commands.py --- voctomix-1.3+git20200101/voctocore/lib/commands.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/commands.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,26 +1,23 @@ -#!/usr/bin/env python3 import logging import json import inspect from lib.config import Config +from lib.videomix import CompositeModes from lib.response import NotifyResponse, OkResponse from lib.sources import restart_source -from vocto.composite_commands import CompositeCommand -from vocto.command_helpers import quote, dequote, str2bool -import os -class ControlServerCommands(object): +class ControlServerCommands(object): def __init__(self, pipeline): self.log = logging.getLogger('ControlServerCommands') self.pipeline = pipeline self.stored_values = {} - self.sources = Config.getSources() - self.blinder_sources = Config.getBlinderSources() - self.streams = Config.getAudioStreams().get_stream_names() + self.sources = Config.getlist('mix', 'sources') + if Config.getboolean('stream-blanker', 'enabled'): + self.blankerSources = Config.getlist('stream-blanker', 'sources') # Commands are defined below. Errors are sent to the clients by throwing # exceptions, they will be turned into messages outside. @@ -85,48 +82,55 @@ for source in self.sources: helplines.append("\t" + source) - if Config.getBlinderEnabled(): + if Config.getboolean('stream-blanker', 'enabled'): helplines.append("\n") helplines.append("Stream-Blanker Sources-Names:") - for source in self.blinder_sources: + for source in self.blankerSources: helplines.append("\t" + source) + helplines.append("\n") + helplines.append("Composition-Modes:") + for mode in CompositeModes: + helplines.append("\t" + mode.name) + return OkResponse("\n".join(helplines)) def _get_video_status(self): - a = self.pipeline.vmix.getVideoSourceA() - b = self.pipeline.vmix.getVideoSourceB() + a = self.sources[self.pipeline.vmix.getVideoSourceA()] + b = self.sources[self.pipeline.vmix.getVideoSourceB()] return [a, b] def get_video(self): """gets the current video-status, consisting of the name of video-source A and video-source B""" - status = self.pipeline.vmix.getVideoSources() + status = self._get_video_status() return OkResponse('video_status', *status) def set_video_a(self, src_name): """sets the video-source A to the supplied source-name or source-id, swapping A and B if the supplied source is currently used as video-source B""" - self.pipeline.vmix.setVideoSourceA(src_name) + src_id = self.sources.index(src_name) + self.pipeline.vmix.setVideoSourceA(src_id) - status = self.pipeline.vmix.getVideoSources() + status = self._get_video_status() return NotifyResponse('video_status', *status) def set_video_b(self, src_name): """sets the video-source B to the supplied source-name or source-id, swapping A and B if the supplied source is currently used as video-source A""" - self.pipeline.vmix.setVideoSourceB(src_name) + src_id = self.sources.index(src_name) + self.pipeline.vmix.setVideoSourceB(src_id) - status = self.pipeline.vmix.getVideoSources() + status = self._get_video_status() return NotifyResponse('video_status', *status) def _get_audio_status(self): volumes = self.pipeline.amix.getAudioVolumes() return json.dumps({ - self.streams[idx]: round(volume, 4) + self.sources[idx]: round(volume, 4) for idx, volume in enumerate(volumes) }) @@ -145,44 +149,45 @@ def set_audio_volume(self, src_name, volume): """sets the volume of the supplied source-name or source-id""" + src_id = self.sources.index(src_name) volume = float(volume) if volume < 0.0: raise ValueError("volume must be positive") - if src_name == 'mix': - self.pipeline.amix.setAudioVolume(volume) - else: - self.pipeline.amix.setAudioSourceVolume(self.streams.index(src_name), volume) + self.pipeline.amix.setAudioSourceVolume(src_id, volume) status = self._get_audio_status() return NotifyResponse('audio_status', status) + def _get_composite_status(self): + mode = self.pipeline.vmix.getCompositeMode() + return mode.name + def get_composite_mode(self): """gets the name of the current composite-mode""" - status = self.pipeline.vmix.getCompositeMode() + status = self._get_composite_status() return OkResponse('composite_mode', status) def get_composite_modes(self): """lists the names of all available composite-mode""" - # TODO: fix this... - #names = [mode.name for mode in CompositeModes] - names = [""] + names = [mode.name for mode in CompositeModes] namestr = ','.join(names) return OkResponse('composite_modes', namestr) def get_composite_mode_and_video_status(self): """retrieves the composite-mode and the video-status in a single call""" - composite_status = self.pipeline.vmix.getCompositeMode() - video_status = self.pipeline.vmix.getVideoSources() + composite_status = self._get_composite_status() + video_status = self._get_video_status() return OkResponse('composite_mode_and_video_status', composite_status, *video_status) def set_composite_mode(self, mode_name): """sets the name of the id of the composite-mode""" - self.pipeline.vmix.setComposite(CompositeCommand(mode_name, "*", "*")) + mode = CompositeModes[mode_name] + self.pipeline.vmix.setCompositeMode(mode) - composite_status = self.pipeline.vmix.getCompositeMode() - video_status = self.pipeline.vmix.getVideoSources() + composite_status = self._get_composite_status() + video_status = self._get_video_status() return [ NotifyResponse('composite_mode', composite_status), NotifyResponse('video_status', *video_status), @@ -190,49 +195,29 @@ composite_status, *video_status), ] - def transition(self, command): - """sets the composite and sources by using the composite command format - (e.g. 'sbs(cam1,cam2)') as the only parameter - """ - self.pipeline.vmix.setComposite(command, True) - return NotifyResponse('composite', self.pipeline.vmix.getComposite()) - - def best(self, command): - """tests if transition to the composite described by command is possible. - """ - transition = self.pipeline.vmix.testTransition(command) - if transition: - return OkResponse('best','transition', *transition) - else: - cut = self.pipeline.vmix.testCut(command) - if cut: - return OkResponse('best','cut', *cut) - else: - command = CompositeCommand.from_str(command) - return OkResponse('best','none',command.A,command.B) - - def cut(self, command): - """sets the composite and sources by using the composite command format - (e.g. 'sbs(cam1,cam2)') as the only parameter - """ - self.pipeline.vmix.setComposite(command, False) - return NotifyResponse('composite', self.pipeline.vmix.getComposite()) - - def get_composite(self): - """fetch current composite and sources using the composite command format - (e.g. 'sbs(cam1,cam2)') as return value - """ - return OkResponse('composite', self.pipeline.vmix.getComposite()) - def set_videos_and_composite(self, src_a_name, src_b_name, mode_name): """sets the A- and the B-source synchronously with the composition-mode all parametets can be set to "*" which will leave them unchanged.""" - self.pipeline.vmix.setComposite( - str(CompositeCommand(mode_name, src_a_name, src_b_name))) + if src_a_name != '*': + src_a_id = self.sources.index(src_a_name) + self.pipeline.vmix.setVideoSourceA(src_a_id) + + if src_b_name != '*': + src_b_id = self.sources.index(src_b_name) + self.pipeline.vmix.setVideoSourceB(src_b_id) + + if mode_name != '*': + mode = CompositeModes[mode_name] + called_with_source = \ + src_a_name != '*' or \ + src_b_name != '*' + + self.pipeline.vmix.setCompositeMode( + mode, apply_default_source=not called_with_source) - composite_status = self.pipeline.vmix.getCompositeMode() - video_status = self.pipeline.vmix.getVideoSources() + composite_status = self._get_composite_status() + video_status = self._get_video_status() return [ NotifyResponse('composite_mode', composite_status), @@ -241,36 +226,31 @@ composite_status, *video_status), ] - if Config.getBlinderEnabled(): + if Config.getboolean('stream-blanker', 'enabled'): def _get_stream_status(self): - blind_source = self.pipeline.blinder.blind_source - if blind_source is None: + blankSource = self.pipeline.streamblanker.blankSource + if blankSource is None: return ('live',) - return 'blinded', self.blinder_sources[blind_source] + return 'blank', self.blankerSources[blankSource] def get_stream_status(self): - """gets the current blinder-status""" + """gets the current streamblanker-status""" status = self._get_stream_status() return OkResponse('stream_status', *status) - def set_stream_blind(self, source_name): - """sets the blinder-status to blinder with the specified - blinder-source-name or -id""" - src_id = self.blinder_sources.index(source_name) - self.pipeline.blinder.setBlindSource(src_id) + def set_stream_blank(self, source_name): + """sets the streamblanker-status to blank with the specified + blanker-source-name or -id""" + src_id = self.blankerSources.index(source_name) + self.pipeline.streamblanker.setBlankSource(src_id) status = self._get_stream_status() return NotifyResponse('stream_status', *status) - # for backwards compatibility this command remains obsolete - def set_stream_blank(self, source_name): - - return self.set_stream_blind(source_name) - def set_stream_live(self): - """sets the blinder-status to live""" - self.pipeline.blinder.setBlindSource(None) + """sets the streamblanker-status to live""" + self.pipeline.streamblanker.setBlankSource(None) status = self._get_stream_status() return NotifyResponse('stream_status', *status) @@ -290,56 +270,3 @@ """restarts the specified source""" restart_source(src_name) return OkResponse('source_restarted', src_name) - - def report_queues(self): - report = dict() - for queue in self.pipeline.queues: - report[queue.name] = queue.get_property("current-level-time") - return OkResponse('queue_report', json.dumps(report)) - - def report_ports(self): - for p in self.pipeline.ports: - p.update() - return OkResponse('port_report', json.dumps(self.pipeline.ports, default=lambda x: x.todict())) - - # only available when overlays are configured - if Config.hasOverlay(): - - def set_overlay(self, overlay): - """set an overlay and show""" - # decode parameter to filename - filename = Config.getOverlayFilePath(dequote(overlay)) - # check if file exists - if os.path.isfile(filename): - # select overlay in mixing pipeline - self.pipeline.vmix.setOverlay(filename) - else: - # tell log about file that could not be found - self.log.error( - "Overlay file '{}' not found".format(filename)) - # respond with current overlay notification - return self.get_overlay() - - def show_overlay(self, visible): - """set an overlay and show""" - # show or hide overlay in mixing pipeline - self.pipeline.vmix.showOverlay(str2bool(visible)) - # respond with overlay visibility notification - return self.get_overlay_visible() - - def get_overlay(self): - """respond any visible overlay""" - return NotifyResponse('overlay', quote(Config.getOverlayNameFromFilePath(self.pipeline.vmix.getOverlay()))) - - def get_overlay_visible(self): - """respond any visible overlay""" - return NotifyResponse('overlay_visible', str(self.pipeline.vmix.getOverlayVisible())) - - def get_overlays_title(self): - """respond with list of all available overlays""" - return NotifyResponse('overlays_title', - ",".join(quote(t) for t in Config.getOverlaysTitle())) - - def get_overlays(self): - return NotifyResponse('overlays', - ",".join([quote(a) for a in Config.getOverlayFiles()])) diff -Nru voctomix-1.3+git20200101/voctocore/lib/config.py voctomix-1.3+git20200102/voctocore/lib/config.py --- voctomix-1.3+git20200101/voctocore/lib/config.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/config.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,37 +1,21 @@ -#!/usr/bin/env python3 import os.path import logging -from configparser import DuplicateSectionError +from configparser import SafeConfigParser, DuplicateSectionError from lib.args import Args -from lib.sources import kind_has_audio -from vocto.config import VocConfigParser -import xml.etree.ElementTree as ET -from datetime import date, datetime, timedelta -import re -import os __all__ = ['Config'] Config = None -def scandatetime(str): - return datetime.strptime(str[:19], "%Y-%m-%dT%H:%M:%S") +class VocConfigParser(SafeConfigParser): + def getlist(self, section, option): + option = self.get(section, option).strip() + if len(option) == 0: + return [] - -def scanduration(str): - r = re.match(r'^(\d+):(\d+)$', str) - return timedelta(hours=int(r.group(1)), minutes=int(r.group(2))) - - -class VoctocoreConfigParser(VocConfigParser): - - def __init__(self): - super().__init__() - self.events = [] - self.event_now = None - self.events_update = None - self.default_insert = None + unfiltered = [x.strip() for x in option.split(',')] + return list(filter(None, unfiltered)) def add_section_if_missing(self, section): try: @@ -39,247 +23,38 @@ except DuplicateSectionError: pass - def getOverlayFile(self): - ''' return overlay/file or from INI configuration ''' - if self.has_option('overlay', 'file'): - return self.getOverlayNameFromFilePath(self.get('overlay', 'file')) - else: - return None - - def getScheduleRoom(self): - ''' return overlay/room or from INI configuration ''' - if self.has_option('overlay', 'room'): - return self.get('overlay', 'room') - else: - return None - - def getScheduleEvent(self): - ''' return overlay/event or from INI configuration ''' - if self.has_option('overlay', 'event'): - if self.has_option('overlay', 'room'): - self.log.warning( - "'overlay'/'event' overwrites 'overlay'/'room'") - return self.get('overlay', 'event') - else: - return None - - def getSchedule(self): - ''' return overlay/schedule or from INI configuration ''' - if self.has_option('overlay', 'schedule'): - if self.has_option('overlay', 'room') or self.has_option('overlay', 'event'): - return self.get('overlay', 'schedule') - else: - # warn if no room has been defined - self.log.error( - "configuration option 'overlay'/'schedule' ignored when not defining 'room' or 'event' too") - return None - - def _getEvents(self): - # check if file has been changed before re-read - self.events = None - # parse XML and iterate all schedule/day elements - self.events = ET.parse( - self.getSchedule()).getroot() .findall('day/room/event') - self.log.info("read {n} events from file \'{f}\'".format( - n=len(self.events), f=self.getSchedule())) - # remember the update time - self.events_update = os.path.getmtime(self.getSchedule()) - return self.events - - def _getEventNow(self): - # get currnet date and time - now = datetime.now() - # check for option overlay/event - if self.getScheduleEvent(): - # find event by ID - for event in self._getEvents(): - if event.get('id') == self.getScheduleEvent(): - # remember current event - self.event_now = event - else: - # check if there is no event already running - if (not self.event_now) or now > scandatetime(self.event_now.find('date').text) + scanduration(self.event_now.find('duration').text): - # inistialize a past date - past = datetime(1999, 1, 1) - # remember nearest start time - nowest = past - # iterate events - for event in self._getEvents(): - # check for room - if event.find('room').text == self.getScheduleRoom() or not self.getScheduleRoom(): - # get start time - time = scandatetime(event.find('date').text) - # time nearer then nowest - if now >= time and time > nowest: - # remember new nearest time - nowest = time - # rememeber current event - self.event_now = event - return self.event_now - - def getOverlaysPath(self): - ''' return overlays path or $PWD from INI configuration ''' - if self.has_option('overlay', 'path'): - return os.path.abspath(self.get('overlay', 'path')) - else: - return os.getcwd() - - def getOverlaysTitle(self): - if self.getSchedule(): - try: - event = self._getEventNow() - if event: - at = scandatetime(event.find('date').text) - return (at.strftime("%Y-%m-%d %H:%M"), - (at + scanduration(event.find('duration').text) - ).strftime("%Y-%m-%d %H:%M"), - event.get('id'), - event.find('title').text) - except FileNotFoundError: - self.log.error( - 'schedule file \'%s\' not found', self.getSchedule()) - return None - - def getOverlayFilePath(self, overlay): - ''' return absolute file path to overlay by given string of overlay - file name (and maybe |-separated name) - ''' - # return None if None was given - if not overlay: - return None - # split overlay by "|" if applicable - filename, name = overlay.split( - "|") if "|" in overlay else (overlay, None) - # add PNG default extension if not already within filename - filename = filename + \ - ".png" if filename and ( - len(filename) < 4 or filename[-4:].lower()) != ".png" else filename - # return absolute path of filename - return os.path.join(self.getOverlaysPath(), filename) - - def getOverlayNameFromFilePath(self, filepath): - ''' return overlay name from filepath (which may have |-separated - name attached) - ''' - # return None if None was given - if not filepath: - return None - # split filepath by "|" if applicable - filepath, name = filepath.split( - "|") if '|' in filepath else (filepath, None) - # remove overlay path - filename = filepath.replace(self.getOverlaysPath() + os.sep, "") - # remove PNG extension - filename = filename[:-4] if filename and len( - filename) > 4 and filename[-4:].lower() == ".png" else filename - # attach name again if it was given - return "|".join((filename, name)) if name else filename - - def getOverlayFiles(self): - ''' generate list of available overlay files by the following opportunities: - - by 'schedule' (and optionally 'room') from section 'overlay' - - by 'files' from section 'overlay' - - by 'file' from section 'overlay' - ''' - # initialize empty inserts list - inserts = [] - - # checkt for overlay/schedule option - if self.getSchedule(): - try: - - def generate(event): - ''' return all available insert names for event ''' - # get list of persons from event - persons = event.findall("persons/person") - # generate insert file names and names - inserts = ["event_{eid}_person_{pid}|{text}".format( - eid=event.get('id'), - pid=person.get('id'), - text=person.text) for person in persons] - # add a insert for all persons together - if len(persons) > 1: - inserts += ["event_{eid}_persons|{text}".format( - eid=event.get('id'), - text=", ".join([person.text for person in persons]))] - return inserts - - # get current event - event = self._getEventNow() - # generate inserts from event - inserts = generate(event) - # if empty show warning - if not inserts: - self.log.warning('schedule file \'%s\' contains no information for inserts of event #%s', - self.getSchedule(), - event.get('id')) - except FileNotFoundError: - # show error at file not found - self.log.error('schedule file \'%s\' not found', - self.getSchedule()) - # check for overlay/files option - if self.has_option('overlay', 'files'): - # add inserts from files - inserts += [self.getOverlayNameFromFilePath(o) - for o in self.getList('overlay', 'files')] - # check overlay/file option - if self.getOverlayFile(): - # append this file if not already in list - if not self.getOverlayNameFromFilePath(self.getOverlayFile()) in inserts: - inserts += [self.getOverlayNameFromFilePath(self.getOverlayFile())] - # make a list of inserts with existing image files - valid = [] - for i in inserts: - # get absolute file path - filename = self.getOverlayFilePath(i.split('|')[0]) - if os.path.isfile(filename): - # append to valid if existing - valid.append(i) - else: - # report error if not found - self.log.error( - 'Could not find overlay image file \'%s\'.' % filename ) - # check if there is any useful result - if valid: - self.default_insert = valid[0] - self.log.info('found %d insert(s): %s', - len(valid), - ",".join([i for i in valid])) - else: - self.default_insert = None - self.log.warning( - 'Could not find any availbale overlays in configuration.') - return valid - - def getOverlayBlendTime(self): - ''' return overlay blending time in milliseconds from INI configuration ''' - if self.has_option('overlay', 'blend'): - return int(self.get('overlay', 'blend')) - else: - return 300 - - def getAudioSources(self, internal=False): - def source_has_audio(source): - return kind_has_audio(self.getSourceKind(source)) - sources = self.getSources() - if internal: - sources += ['mix'] - if self.getBlinderEnabled(): - sources += ['blinder', 'mix-blinded'] - return list(filter(source_has_audio, sources)) - def load(): global Config + files = [ + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../default-config.ini'), + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../config.ini'), + '/etc/voctomix/voctocore.ini', + '/etc/voctomix.ini', # deprecated + '/etc/voctocore.ini', + os.path.expanduser('~/.voctomix.ini'), # deprecated + os.path.expanduser('~/.voctocore.ini'), + ] - Config = VoctocoreConfigParser() + if Args.ini_file is not None: + files.append(Args.ini_file) - config_file_name = Args.ini_file if Args.ini_file else os.path.join( - os.path.dirname(os.path.realpath(__file__)), '../default-config.ini') - readfiles = Config.read([config_file_name]) + Config = VocConfigParser() + readfiles = Config.read(files) log = logging.getLogger('ConfigParser') - log.debug("successfully parsed config-file: '%s'", config_file_name) + log.debug('considered config-files: \n%s', + "\n".join([ + "\t\t" + os.path.normpath(file) + for file in files + ])) + log.debug('successfully parsed config-files: \n%s', + "\n".join([ + "\t\t" + os.path.normpath(file) + for file in readfiles + ])) if Args.ini_file is not None and Args.ini_file not in readfiles: raise RuntimeError('explicitly requested config-file "{}" ' diff -Nru voctomix-1.3+git20200101/voctocore/lib/controlserver.py voctomix-1.3+git20200102/voctocore/lib/controlserver.py --- voctomix-1.3+git20200101/voctocore/lib/controlserver.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/controlserver.py 2020-01-03 00:02:24.000000000 +0000 @@ -6,15 +6,13 @@ from lib.tcpmulticonnection import TCPMultiConnection from lib.response import NotifyResponse -from vocto.port import Port - class ControlServer(TCPMultiConnection): def __init__(self, pipeline): '''Initialize server and start listening.''' self.log = logging.getLogger('ControlServer') - super().__init__(port=Port.CORE_LISTENING) + super().__init__(port=9999) self.command_queue = Queue() @@ -93,22 +91,22 @@ 'stopping on_loop scheduling') return True - self.log.info("processing command '%s'", ' '.join(words)) - command = words[0] args = words[1:] + self.log.info("processing command %r with args %s", command, args) + response = None try: # deny calling private methods if command[0] == '_': - self.log.info('Private methods are not callable') + self.log.info('private methods are not callable') raise KeyError() command_function = self.commands.__class__.__dict__[command] except KeyError as e: - self.log.info("Received unknown command %s", command) + self.log.info("received unknown command %s", command) response = "error unknown command %s\n" % command else: @@ -160,10 +158,9 @@ return False message = queue.get() - self.log.info("Responding message '%s'", message.strip()) try: conn.send(message.encode()) except Exception as e: - self.log.warning("Failed to send message '%s'", message.encode(), exc_info=True) + self.log.warning('failed to send message', exc_info=True) return True diff -Nru voctomix-1.3+git20200101/voctocore/lib/debug.py voctomix-1.3+git20200102/voctocore/lib/debug.py --- voctomix-1.3+git20200101/voctocore/lib/debug.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/debug.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -import os -import logging -from gi.repository import Gst -import gi -gi.require_version('Gst', '1.0') - -log = logging.getLogger('voctocore.debug') - -def gst_generate_dot(pipeline, name, log): - from lib.args import Args - dotfile = os.path.join(os.environ['GST_DEBUG_DUMP_DOT_DIR'], "%s.dot" % name) - log.debug("Generating DOT image of pipeline '{name}' into '{file}'".format(name=name, file=dotfile)) - Gst.debug_bin_to_dot_file(pipeline, Gst.DebugGraphDetails(Args.gst_debug_details), name) - - -gst_log_messages_lastmessage = None -gst_log_messages_lastlevel = None -gst_log_messages_repeat = 0 - -def gst_log_messages(level): - - gstLog = logging.getLogger('Gst') - - def log( level, msg ): - if level == Gst.DebugLevel.WARNING: - gstLog.warning(msg) - if level == Gst.DebugLevel.FIXME: - gstLog.warning(msg) - elif level == Gst.DebugLevel.ERROR: - gstLog.error(msg) - elif level == Gst.DebugLevel.INFO: - gstLog.info(msg) - elif level == Gst.DebugLevel.DEBUG: - gstLog.debug(msg) - - def logFunction(category, level, file, function, line, object, message, *user_data): - global gst_log_messages_lastmessage, gst_log_messages_lastlevel, gst_log_messages_repeat - - msg = message.get() - if gst_log_messages_lastmessage != msg: - if gst_log_messages_repeat > 2: - log(gst_log_messages_lastlevel,"%s [REPEATING %d TIMES]" % (gst_log_messages_lastmessage, gst_log_messages_repeat)) - - gst_log_messages_lastmessage = msg - gst_log_messages_repeat = 0 - gst_log_messages_lastlevel = level - log(level,"%s: %s (in function %s() in file %s:%d)" % (object.name if object else "", msg, function, file, line)) - else: - gst_log_messages_repeat += 1 - - - Gst.debug_remove_log_function(None) - Gst.debug_add_log_function(logFunction,None) - Gst.debug_set_default_threshold(level) - Gst.debug_set_active(True) - diff -Nru voctomix-1.3+git20200101/voctocore/lib/__init__.py voctomix-1.3+git20200102/voctocore/lib/__init__.py --- voctomix-1.3+git20200101/voctocore/lib/__init__.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/__init__.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,3 +0,0 @@ -import sys -sys.path.insert(0, '..') -sys.path.insert(0, '.') diff -Nru voctomix-1.3+git20200101/voctocore/lib/loghandler.py voctomix-1.3+git20200102/voctocore/lib/loghandler.py --- voctomix-1.3+git20200101/voctocore/lib/loghandler.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/loghandler.py 2020-01-03 00:02:24.000000000 +0000 @@ -15,18 +15,12 @@ c_mod = 32 c_msg = 0 - if record.levelno <= logging.DEBUG: - c_msg = 90 - - elif record.levelno <= logging.INFO: - c_lvl = 37 - c_msg = 97 - - elif record.levelno <= logging.WARNING: + if record.levelno == logging.WARNING: c_lvl = 31 + # c_mod = 33 c_msg = 33 - else: + elif record.levelno > logging.WARNING: c_lvl = 31 c_mod = 31 c_msg = 31 diff -Nru voctomix-1.3+git20200101/voctocore/lib/overlay.py voctomix-1.3+git20200102/voctocore/lib/overlay.py --- voctomix-1.3+git20200101/voctocore/lib/overlay.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/overlay.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -from gi.repository import Gst, GstController -import logging -import gi -gi.require_version('GstController', '1.0') - - -class Overlay: - log = logging.getLogger('Overlay') - - def __init__(self, pipeline, location=None, blend_time=300): - # get overlay element and config - self.overlay = pipeline.get_by_name('overlay') - self.location = location - self.isVisible = location != None - self.blend_time = blend_time - - # initialize overlay control binding - self.alpha = GstController.InterpolationControlSource() - self.alpha.set_property('mode', GstController.InterpolationMode.LINEAR) - cb = GstController.DirectControlBinding.new_absolute(self.overlay, 'alpha', self.alpha) - self.overlay.add_control_binding(cb) - - def set( self, location ): - self.location = location if location else "" - if self.isVisible: - self.overlay.set_property('location', self.location ) - - def show(self, visible, playtime): - ''' set overlay visibility ''' - # check if control binding is available - assert self.alpha - # if state changes - if self.isVisible != visible: - # set blending animation - if self.blend_time > 0: - self.alpha.set(playtime, 0.0 if visible else 1.0) - self.alpha.set(playtime + int(Gst.SECOND / 1000.0 * self.blend_time), 1.0 if visible else 0.0) - else: - self.alpha.set(playtime, 1.0 if visible else 0.0) - # set new visibility state - self.isVisible = visible - # re-set location if we get visible - if visible: - self.overlay.set_property('location', self.location ) - - def get(self): - ''' get current overlay file location ''' - return self.location - - def visible(self): - ''' get overlay visibility ''' - return self.isVisible diff -Nru voctomix-1.3+git20200101/voctocore/lib/pipeline.py voctomix-1.3+git20200102/voctocore/lib/pipeline.py --- voctomix-1.3+git20200101/voctocore/lib/pipeline.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/pipeline.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,9 +1,4 @@ -#!/usr/bin/env python3 import logging -import re -import sys - -from gi.repository import Gst # import library components from lib.config import Config @@ -12,187 +7,119 @@ from lib.avpreviewoutput import AVPreviewOutput from lib.videomix import VideoMix from lib.audiomix import AudioMix -from lib.blinder import Blinder -from lib.args import Args -from lib.clock import Clock -from vocto.port import Port -from vocto.debug import gst_generate_dot -from vocto.pretty import pretty +from lib.streamblanker import StreamBlanker + class Pipeline(object): """mixing, streaming and encoding pipeline constuction and control""" def __init__(self): self.log = logging.getLogger('Pipeline') - # log capabilities - self.log.info('Video-Caps configured to: %s', Config.getVideoCaps()) - self.log.info('Audio-Caps configured to: %s', Config.getAudioCaps()) - - # get A/B sources from config - sources = Config.getSources() - if len(sources) < 1: + self.log.info('Video-Caps configured to: %s', + Config.get('mix', 'videocaps')) + self.log.info('Audio-Caps configured to: %s', + Config.get('mix', 'audiocaps')) + + names = Config.getlist('mix', 'sources') + if len(names) < 1: raise RuntimeError("At least one AVSource must be configured!") - # collect bins for all modules - self.bins = [] - self.ports = [] - - # create A/V sources - self.log.info('Creating %u AVSources: %s', len(sources), sources) - for idx, source_name in enumerate(sources): - # count port and create source - source = spawn_source(source_name, Port.SOURCES_IN + idx) - self.bins.append(source) - self.ports.append(Port(source_name, source)) - - if Config.getMirrorsEnabled(): - if source_name in Config.getMirrorsSources(): - dest = AVRawOutput(source_name, Port.SOURCES_OUT + idx) - self.bins.append(dest) - self.ports.append(Port(source_name, dest)) - - # check for source preview selection - if Config.getPreviewsEnabled(): - # count preview port and create source - dest = AVPreviewOutput(source_name, Port.SOURCES_PREVIEW + idx) - self.bins.append(dest) - self.ports.append(Port("preview-%s" % source_name, dest)) + self.sources = [] + self.mirrors = [] + self.previews = [] + self.sbsources = [] + + self.log.info('Creating %u AVSources: %s', len(names), names) + for idx, name in enumerate(names): + port = 10000 + idx + + outputs = [name + '_mixer'] + if Config.getboolean('previews', 'enabled'): + outputs.append(name + '_preview') + + if Config.getboolean('mirrors', 'enabled'): + outputs.append(name + '_mirror') + + if Config.has_option('mix', 'slides_source_name') and \ + Config.get('mix', 'slides_source_name') == name: + outputs.append('slides_stream-blanker') + + source = spawn_source(name, port, outputs=outputs) + self.log.info('Creating AVSource %s as %s', name, source) + self.sources.append(source) + + if Config.getboolean('mirrors', 'enabled'): + port = 13000 + idx + self.log.info('Creating Mirror-Output for AVSource %s ' + 'at tcp-port %u', name, port) + + mirror = AVRawOutput('%s_mirror' % name, port) + self.mirrors.append(mirror) + + if Config.getboolean('previews', 'enabled'): + port = 14000 + idx + self.log.info('Creating Preview-Output for AVSource %s ' + 'at tcp-port %u', name, port) - # create audio mixer - self.log.info('Creating Audiomixer') - self.amix = AudioMix() - self.bins.append(self.amix) + preview = AVPreviewOutput('%s_preview' % name, port) + self.previews.append(preview) - # create video mixer - self.log.info('Creating Videomixer') + self.log.info('Creating Videmixer') self.vmix = VideoMix() - self.bins.append(self.vmix) - for idx, background in enumerate(Config.getBackgroundSources()): - # create background source - source = spawn_source( - background, Port.SOURCES_BACKGROUND+idx, has_audio=False) - self.bins.append(source) - self.ports.append(Port(background, source)) - - # create mix TCP output - dest = AVRawOutput('mix', Port.MIX_OUT, use_audio_mix=True) - self.bins.append(dest) - self.ports.append(Port('mix', dest)) - - # create mix preview TCP output - if Config.getPreviewsEnabled(): - dest = AVPreviewOutput('mix', Port.MIX_PREVIEW, use_audio_mix=True) - self.bins.append(dest) - self.ports.append(Port('preview-mix', dest)) - - # create blinding sources and mixer - if Config.getBlinderEnabled(): - sources = Config.getBlinderSources() - if len(sources) < 1: - raise RuntimeError('At least one Blinder-Source must ' + self.log.info('Creating Audiomixer') + self.amix = AudioMix() + + port = 16000 + self.bgsrc = spawn_source('background', port, has_audio=False) + self.log.info('Creating Mixer-Background VSource as %s', self.bgsrc) + + port = 11000 + self.log.info('Creating Mixer-Output at tcp-port %u', port) + self.mixout = AVRawOutput('mix_out', port) + + if Config.getboolean('previews', 'enabled'): + port = 12000 + self.log.info('Creating Preview-Output for Mix' + 'at tcp-port %u', port) + + self.mixpreview = AVPreviewOutput('mix_preview', port) + + if Config.getboolean('stream-blanker', 'enabled'): + names = Config.getlist('stream-blanker', 'sources') + if len(names) < 1: + raise RuntimeError('At least one StreamBlanker-Source must ' 'be configured or the ' - 'Blinder disabled!') - if Config.isBlinderDefault(): - source = spawn_source('blinder', - Port.SOURCES_BLANK) - self.bins.append(source) - self.ports.append(Port('blinder', source)) - else: - for idx, source_name in enumerate(sources): - source = spawn_source(source_name, - Port.SOURCES_BLANK + idx, - has_audio=False) - self.bins.append(source) - self.ports.append(Port('blinded-{}'.format(source_name), source)) - - source = spawn_source('blinder', - Port.AUDIO_SOURCE_BLANK, - has_video=False) - self.bins.append(source) - self.ports.append(Port('blinder-audio', source)) - - self.log.info('Creating Blinder') - self.blinder = Blinder() - self.bins.append(self.blinder) - - # check for source preview selection - if Config.getPreviewsEnabled(): - for idx, livepreview in enumerate(Config.getLivePreviews()): - dest = AVPreviewOutput('{}-blinded'.format(livepreview), Port.LIVE_PREVIEW+idx, use_audio_mix=True, audio_blinded=True) - self.bins.append(dest) - self.ports.append(Port('preview-{}-live'.format(livepreview), dest)) - - for idx, livesource in enumerate(Config.getLiveSources()): - dest = AVRawOutput('{}-blinded'.format(livesource), Port.LIVE_OUT + idx, use_audio_mix=True, audio_blinded=True ) - self.bins.append(dest) - self.ports.append(Port('{}-live'.format(livesource), dest)) - - for bin in self.bins: - self.log.info("%s\n%s", bin, pretty(bin.bin)) - - # concatinate pipeline string - pipeline = "\n\n".join(bin.bin for bin in self.bins) - - if Args.pipeline: - with open("core.pipeline.txt","w") as file: - file.write(pretty(pipeline)) - - self.prevstate = None - try: - # launch gstreamer pipeline - self.pipeline = Gst.parse_launch(pipeline) - self.log.info("pipeline launched successfuly") - except: - self.log.error("Can not launch pipeline") - sys.exit(-1) - - # attach pads - for bin in self.bins: - bin.attach(self.pipeline) - - self.pipeline.use_clock(Clock) - - # fetch all queues - self.queues = self.fetch_elements_by_name(r'^queue-[\w_-]+$') - - self.log.debug('Binding End-of-Stream-Signal on Source-Pipeline') - self.pipeline.bus.add_signal_watch() - self.pipeline.bus.connect("message::eos", self.on_eos) - self.pipeline.bus.connect("message::error", self.on_error) - self.pipeline.bus.connect( - "message::state-changed", self.on_state_changed) - - self.pipeline.set_state(Gst.State.PLAYING) - - def fetch_elements_by_name(self, regex): - # fetch all watchdogs - result = [] - - def query(element): - if re.match(regex, element.get_name()): - result.append(element) - self.pipeline.iterate_recurse().foreach(query) - return result - - def on_eos(self, bus, message): - self.log.debug('Received End-of-Stream-Signal on Source-Pipeline') - - def on_error(self, bus, message): - (error, debug) = message.parse_error() - self.log.error("GStreamer pipeline element '%s' signaled an error #%u: %s" % (message.src.name, error.code, error.message) ) - sys.exit(-1) - - def on_state_changed(self, bus, message): - newstate = message.parse_state_changed().newstate - states = ["PENDING", "NULL", "READY", "PAUSED", "PLAYING"] - self.log.debug("element state changed to '%s' by element '%s'", states[newstate], message.src.name ) - if self.prevstate != newstate and message.src.name == "pipeline0": - self.prevstate = newstate - self.log.debug("pipeline state changed to '%s'", states[newstate] ) - if newstate == Gst.State.PLAYING: - self.log.info("\n\n====================== UP AN RUNNING ======================\n" ) - - if Args.dot or Args.gst_debug_details: - # make DOT file from pipeline - gst_generate_dot(self.pipeline, "core.pipeline") + 'StreamBlanker disabled!') + for idx, name in enumerate(names): + port = 17000 + idx + + source = spawn_source('stream-blanker-{}'.format(name), port, + has_audio=False) + self.log.info('Creating StreamBlanker VSource %s as %s', + name, source) + self.sbsources.append(source) + + port = 18000 + self.log.info('Creating StreamBlanker ASource at tcp-port %u', + port) + + source = spawn_source('stream-blanker', + port, + has_video=False, + force_num_streams=1) + self.sbsources.append(source) + + self.log.info('Creating StreamBlanker') + self.streamblanker = StreamBlanker() + + port = 15000 + self.log.info('Creating StreamBlanker-Output at tcp-port %u', port) + self.streamout = AVRawOutput('stream-blanker_out', port) + + if Config.has_option('mix', 'slides_source_name'): + port = 15001 + self.log.info( + 'Creating SlideStreamBlanker-Output at tcp-port %u', port) + self.slides_streamout = AVRawOutput( + 'slides_stream-blanker_out', port) diff -Nru voctomix-1.3+git20200101/voctocore/lib/scene.py voctomix-1.3+git20200102/voctocore/lib/scene.py --- voctomix-1.3+git20200101/voctocore/lib/scene.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/scene.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -import logging -import gi -gi.require_version('GstController', '1.0') -from gi.repository import Gst, GstController -from vocto.transitions import Frame, L, T, R, B - -class Scene: - """ Scene is the adaptor between the gstreamer compositor - and voctomix frames. - With commit() you add frames at a specified play time - """ - log = logging.getLogger('Scene') - - def __init__(self, sources, pipeline, fps, start_sink, cropping=True): - """ initialize with a gstreamer pipeline and names - of the sources to manage - """ - # frames to apply from - self.frames = dict() - # binding pads to apply to - self.pads = dict() - self.cpads = dict() if cropping else None - # time per frame - self.frame_time = int(Gst.SECOND / fps) - - def bind(pad, prop): - """ adds a binding to a gstreamer property - pad's property - """ - # set up a new control source - cs = GstController.InterpolationControlSource() - # stop control source's internal interpolation - cs.set_property( - 'mode', GstController.InterpolationMode.NONE) - # create control binding - cb = GstController.DirectControlBinding.new_absolute( - pad, prop, cs) - # add binding to pad - pad.add_control_binding(cb) - # return binding - return cs - - # walk all sources - for idx, source in enumerate(sources): - # initially invisible - self.frames[source] = None - # get mixer pad from pipeline - mixerpad = (pipeline - .get_by_name('videomixer') - .get_static_pad('sink_%s' % (idx + start_sink))) - # add dictionary of binds to all properties - # we vary for this source - self.pads[source] = { - 'xpos': bind(mixerpad, 'xpos'), - 'ypos': bind(mixerpad, 'ypos'), - 'width': bind(mixerpad, 'width'), - 'height': bind(mixerpad, 'height'), - 'alpha': bind(mixerpad, 'alpha'), - 'zorder': bind(mixerpad, 'zorder'), - } - # get mixer and cropper pad from pipeline - if self.cpads is not None: - cropperpad = (pipeline - .get_by_name("cropper-%s" % source)) - self.cpads[source] = { - 'croptop': bind(cropperpad, 'top'), - 'cropleft': bind(cropperpad, 'left'), - 'cropbottom': bind(cropperpad, 'bottom'), - 'cropright': bind(cropperpad, 'right') - } - # ready to initialize gstreamer - self.dirty = False - - def commit(self, source, frames): - ''' commit multiple frames to the current gstreamer scene ''' - self.log.debug("Commit %d frame(s) to source %s", len(frames), source) - self.frames[source] = frames - self.dirty = True - - def set(self, source, frame): - ''' commit single frame to the current gstreamer scene ''' - self.log.debug("Set frame to source %s", source) - self.frames[source] = [frame] - self.dirty = True - - def push(self, at_time=0): - ''' apply all committed frames to GStreamer pipeline ''' - # get pad for given source - for source, frames in self.frames.items(): - if not frames: - frames = [Frame(zorder=-1,alpha=0)] - self.log.info("Pushing %d frame(s) to source '%s' at time %dms", len( - frames), source, at_time / Gst.MSECOND) - # reset time - time = at_time - # get GStreamer property pad for this source - pad = self.pads[source] - cpad = self.cpads[source] if self.cpads else None - self.log.debug(" %s", Frame.str_title()) - # apply all frames of this source to GStreamer pipeline - for idx, frame in enumerate(frames): - self.log.debug("%2d: %s", idx, frame) - cropped = frame.cropped() - alpha = frame.float_alpha() - # transmit frame properties into mixing pipeline - pad['xpos'].set(time, cropped[L]) - pad['ypos'].set(time, cropped[T]) - pad['width'].set(time, cropped[R] - cropped[L]) - pad['height'].set(time, cropped[B] - cropped[T]) - pad['alpha'].set(time, alpha) - pad['zorder'].set(time, frame.zorder if alpha != 0 else -1) - if cpad: - cpad['croptop'].set(time, frame.crop[T]) - cpad['cropleft'].set(time, frame.crop[L]) - cpad['cropbottom'].set(time, frame.crop[B]) - cpad['cropright'].set(time, frame.crop[R]) - # next frame time - time += self.frame_time - self.frames[source] = None - self.dirty = False diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/avsource.py voctomix-1.3+git20200102/voctocore/lib/sources/avsource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/avsource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/avsource.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,230 +1,136 @@ -#!/usr/bin/env python3 import logging from abc import ABCMeta, abstractmethod -from gi.repository import GLib + +from gi.repository import Gst from lib.config import Config -from lib.args import Args +from lib.clock import Clock class AVSource(object, metaclass=ABCMeta): + def __init__(self, name, outputs=None, + has_audio=True, has_video=True, + force_num_streams=None): + if not self.log: + self.log = logging.getLogger('AVSource[{}]'.format(name)) - def __init__(self, - class_name, - name, - has_audio=True, - has_video=True, - num_streams=None, - show_no_signal=False): - # create logging interface - self.log = logging.getLogger("%s[%s]" % (class_name, name)) + if outputs is None: + outputs = [name] - # make sure we have at least something assert has_audio or has_video - # remember things - self.class_name = class_name self.name = name self.has_audio = has_audio self.has_video = has_video - # fetch audio streams from config (different for blinder source) - if name == "blinder": - self.audio_streams = Config.getBlinderAudioStreams() - else: - self.audio_streams = Config.getAudioStreams() - # remember if we shall show no-signal underlay - self.show_no_signal = show_no_signal and Config.getNoSignal() - - # maybe initialize no signal watch dog - if self.show_no_signal: - # check if we have video to show no-signal message - assert self.has_video - # set timeout at which we check for signal loss - GLib.timeout_add(self.timer_resolution * 1000, self.do_timeout) - # this might get attached to the no-signal compositor's input sink - self.noSignalSink = None + self.outputs = outputs + self.force_num_streams = force_num_streams + self.pipeline = None - @abstractmethod def __str__(self): - raise NotImplementedError( - '__str__ not implemented for this source') - - def attach(self, pipeline): - if self.show_no_signal: - # attach self.noSignalSink to no-signal compositor - self.noSignalSink = pipeline.get_by_name( - 'compositor-{}'.format(self.name)).get_static_pad('sink_1') - - def build_pipeline(self): - self.bin = "" if Args.no_bins else """ - bin.( - name={class_name}-{name} - """.format(class_name=self.class_name, name=self.name) - - self.bin += self.build_source() - - if self.internal_audio_channels(): - audioport = self.build_audioport() - if audioport: - audio_streams = self.audio_streams.get_stream_names(self.name) - self.bin += """ - {audioport} - ! queue - max-size-time=3000000000 - name=queue-source-audio-{name} - ! tee - name=source-audio-{name} - """.format( + return 'AVSource[{name}]'.format( + name=self.name + ) + + def build_pipeline(self, pipeline): + if self.has_audio: + num_streams = self.force_num_streams + if num_streams is None: + num_streams = Config.getint('mix', 'audiostreams') + + for audiostream in range(0, num_streams): + audioport = self.build_audioport(audiostream) + if not audioport: + continue + + pipeline += """ + {audioport} ! + {acaps} ! + queue ! + tee name=atee_stream{audiostream} + """.format( audioport=audioport, - name=self.name + acaps=Config.get('mix', 'audiocaps'), + audiostream=audiostream, ) - if not audio_streams: - self.bin += """ - source-audio-{name}. - ! queue - max-size-time=3000000000 - name=queue-source-audio-fakesink-{name} - ! fakesink - async=false - """.format(name=self.name) - - for stream in audio_streams: - self.log.info("Creating audio streams '{}' from source '{}'".format(stream,self.name)) - self.bin += """ - source-audio-{name}. - ! queue - max-size-time=3000000000 - name=queue-audiomixmatrix-{stream} - ! audiomixmatrix - name=audiomixmatrix-{stream} - in-channels={in_channels} - out-channels={out_channels} - matrix="{matrix}" - ! {acaps} - ! queue - name=queue-audio-{stream} - max-size-time=3000000000 - ! tee - name=audio-{stream} - """.format( - in_channels=self.internal_audio_channels(), - out_channels=Config.getAudioChannels(), - matrix=str(self.audio_streams.matrix(self.name, - stream, - Config.getAudioChannels(), - grid=self.get_valid_channel_numbers()) - ).replace("[", "<").replace("]", ">"), - acaps=Config.getAudioCaps(), - stream=stream, - name=self.name + + for output in self.outputs: + pipeline += """ + atee_stream{audiostream}. ! queue ! interaudiosink + channel=audio_{output}_stream{audiostream} + """.format( + output=output, + audiostream=audiostream, ) if self.has_video: - if self.show_no_signal and Config.getNoSignal(): - video = """ - videotestsrc - name=canvas-{name} - pattern={nosignalpattern} - ! textoverlay - name=nosignal-{name} - text=\"{nosignal}\" - valignment=center - halignment=center - shaded-background=yes - font-desc="Roboto Bold, 20" - ! {vcaps} - ! queue - max-size-time=3000000000 - ! compositor-{name}. - - {videoport} - ! {vcaps} - ! queue - max-size-time=3000000000 - ! compositor-{name}. - - compositor - name=compositor-{name} - ! queue - max-size-time=3000000000 - ! tee - name=video-{name}""" - else: - video = """ - {videoport} - ! {vcaps} - ! queue - max-size-time=3000000000 - ! tee - name=video-{name}""" - self.bin += video.format( + pipeline += """ + {videoport} ! + {vcaps} ! + queue ! + tee name=vtee + """.format( videoport=self.build_videoport(), - name=self.name, - vcaps=Config.getVideoCaps(), - nosignal=self.get_nosignal_text(), - nosignalpattern=Config.getNoSignal() + deinterlacer=self.build_deinterlacer(), + vcaps=Config.get('mix', 'videocaps') ) - self.bin += "" if Args.no_bins else """ - ) - """ - self.bin = self.bin + for output in self.outputs: + pipeline += """ + vtee. ! queue ! intervideosink channel=video_{output} + """.format( + output=output + ) - def build_source(self): - return "" + self.log.debug('Launching Source-Pipeline:\n%s', pipeline) + self.pipeline = Gst.parse_launch(pipeline) + self.pipeline.use_clock(Clock) + + self.log.debug('Binding End-of-Stream-Signal on Source-Pipeline') + self.pipeline.bus.add_signal_watch() + self.pipeline.bus.connect("message::eos", self.on_eos) + self.pipeline.bus.connect("message::error", self.on_error) def build_deinterlacer(self): - source_mode = Config.getSourceScan(self.name) + deinterlace_config = self.get_deinterlace_config() - if source_mode == "interlaced": + if deinterlace_config == "yes": return "videoconvert ! yadif mode=interlaced" - elif source_mode == "psf": + + elif deinterlace_config == "assume-progressive": return "capssetter " \ "caps=video/x-raw,interlace-mode=progressive" - elif source_mode == "progressive": - return None + + elif deinterlace_config == "no": + return "" + else: raise RuntimeError( "Unknown Deinterlace-Mode on source {} configured: {}". - format(self.name, source_mode)) + format(self.name, deinterlace_config)) - def video_channels(self): - return 1 if self.has_video else 0 - - def audio_channels(self): - return self.audio_streams.num_channels(self.name) if self.has_audio else 0 - - def internal_audio_channels(self): - return self.audio_streams.num_channels(self.name, self.get_valid_channel_numbers()) if self.has_audio else 0 - - def get_valid_channel_numbers(self): - return [x for x in range(1, 255)] - - def num_connections(self): - return 0 - - def is_input(self): - return True - - def section(self): - return 'source.{}'.format(self.name) + def get_deinterlace_config(self): + section = 'source.{}'.format(self.name) + deinterlace_config = Config.get(section, 'deinterlace', fallback="no") + return deinterlace_config + + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Source-Pipeline') + + def on_error(self, bus, message): + self.log.warning('Received Error-Signal on Source-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) @abstractmethod - def port(self): - raise NotImplementedError("port() not implemented in %s" % self.name) - - def build_audioport(self): - raise None + def build_audioport(self, audiostream): + raise NotImplementedError( + 'build_audioport not implemented for this source') + @abstractmethod def build_videoport(self): - raise None - - def get_nosignal_text(self): - return "NO SIGNAL\n" + self.name.upper() + raise NotImplementedError( + 'build_videoport not implemented for this source') - def do_timeout(self): - if self.noSignalSink: - self.noSignalSink.set_property( - 'alpha', 1.0 if self.num_connections() > 0 else 0.0) - # just come back - return True + @abstractmethod + def restart(self): + raise NotImplementedError('Restarting not implemented for this source') diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/decklinkavsource.py voctomix-1.3+git20200102/voctocore/lib/sources/decklinkavsource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/decklinkavsource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/decklinkavsource.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,41 +1,125 @@ -#!/usr/bin/env python3 import logging import re +from gi.repository import Gst + from lib.config import Config from lib.sources.avsource import AVSource class DeckLinkAVSource(AVSource): - timer_resolution = 0.5 + def __init__(self, name, outputs=None, has_audio=True, has_video=True): + self.log = logging.getLogger('DecklinkAVSource[{}]'.format(name)) + super().__init__(name, outputs, has_audio, has_video) + + section = 'source.{}'.format(name) + + # Device number, default: 0 + self.device = Config.get(section, 'devicenumber', fallback=0) + + # Audio connection, default: Automatic + self.aconn = Config.get(section, 'audio_connection', fallback='auto') + + # Video connection, default: Automatic + self.vconn = Config.get(section, 'video_connection', fallback='auto') + + # Video mode, default: 1080i50 + self.vmode = Config.get(section, 'video_mode', fallback='1080i50') + + # Video format, default: auto + self.vfmt = Config.get(section, 'video_format', fallback='auto') + + self.audiostream_map = self._parse_audiostream_map(section) + self.log.info("audiostream_map: %s", self.audiostream_map) + + self.fallback_default = False + if len(self.audiostream_map) == 0: + self.log.info("no audiostream-mapping defined," + "defaulting to mapping channel 0+1 to first stream") + self.fallback_default = True + + self._warn_incorrect_number_of_streams() + + self.required_input_channels = \ + self._calculate_required_input_channels() + + self.log.info("configuring decklink-input to %u channels", + self.required_input_channels) + + min_gst_multi_channels = (1, 12, 3) + if self.required_input_channels > 2 and \ + Gst.version() < min_gst_multi_channels: + + self.log.warning( + 'GStreamer version %s is probably too to use more then 2 ' + 'channels on your decklink source. officially supported ' + 'since %s', + tuple(Gst.version()), min_gst_multi_channels) + + self.launch_pipeline() - def __init__(self, name, has_audio=True, has_video=True): - super().__init__('DecklinkAVSource', name, has_audio, has_video, show_no_signal=True) + def _calculate_required_input_channels(self): + required_input_channels = 0 + for audiostream, mapping in self.audiostream_map.items(): + left, right = self._parse_audiostream_mapping(mapping) + required_input_channels = max(required_input_channels, left + 1) + if right: + required_input_channels = max(required_input_channels, + right + 1) - self.device = Config.getDeckLinkDeviceNumber(name) - self.aconn = Config.getDeckLinkAudioConnection(name) - self.vconn = Config.getDeckLinkVideoConnection(name) - self.vmode = Config.getDeckLinkVideoMode(name) - self.vfmt = Config.getDeckLinkVideoFormat(name) - self.name = name - - self.signalPad = None - self.build_pipeline() - - def port(self): - return "Decklink #{}".format(self.device) - - def attach(self, pipeline): - super().attach(pipeline) - self.signalPad = pipeline.get_by_name( - 'decklinkvideosrc-{}'.format(self.name)) + required_input_channels = \ + self._round_decklink_channels(required_input_channels) - def num_connections(self): - return 1 if self.signalPad and self.signalPad.get_property('signal') else 0 + return required_input_channels - def get_valid_channel_numbers(self): - return (2, 8, 16) + def _round_decklink_channels(self, required_input_channels): + if required_input_channels > 16: + raise RuntimeError( + "Decklink-Devices support up to 16 Channels," + "you requested {}".format(required_input_channels)) + + elif required_input_channels > 8: + required_input_channels = 16 + + elif required_input_channels > 2: + required_input_channels = 8 + + else: + required_input_channels = 2 + + return required_input_channels + + def _parse_audiostream_map(self, config_section): + audiostream_map = {} + + if config_section not in Config: + return audiostream_map + + for key in Config[config_section]: + value = Config.get(config_section, key) + m = re.match(r'audiostream\[(\d+)\]', key) + if m: + audiostream = int(m.group(1)) + audiostream_map[audiostream] = value + + return audiostream_map + + def _parse_audiostream_mapping(self, mapping): + m = re.match(r'(\d+)\+(\d+)', mapping) + if m: + return (int(m.group(1)), int(m.group(2)),) + else: + return (int(mapping), None,) + + def _warn_incorrect_number_of_streams(self): + num_streams = Config.getint('mix', 'audiostreams') + for audiostream, mapping in self.audiostream_map.items(): + if audiostream >= num_streams: + raise RuntimeError( + "Mapping-Configuration for Stream 0 to {} found," + "but only {} enabled" + .format(audiostream, num_streams)) def __str__(self): return 'DecklinkAVSource[{name}] reading card #{device}'.format( @@ -43,63 +127,111 @@ device=self.device ) - def build_source(self): + def launch_pipeline(self): # A video source is required even when we only need audio - pipe = """ + pipeline = """ decklinkvideosrc - name=decklinkvideosrc-{name} device-number={device} connection={conn} video-format={fmt} - mode={mode} - """.format(name=self.name, - device=self.device, - conn=self.vconn, - mode=self.vmode, - fmt=self.vfmt - ) + mode={mode} ! + """.format( + device=self.device, + conn=self.vconn, + mode=self.vmode, + fmt=self.vfmt + ) - # add rest of the video pipeline if self.has_video: - # maybe add deinterlacer - if self.build_deinterlacer(): - pipe += """\ - ! {deinterlacer} - """.format(deinterlacer=self.build_deinterlacer()) - - pipe += """\ - ! videoconvert - ! videoscale - ! videorate - name=vout-{name} - """.format( - deinterlacer=self.build_deinterlacer(), - name=self.name + pipeline += """ + {deinterlacer} + videoconvert ! + videoscale ! + videorate name=vout + """.format( + deinterlacer=self.build_deinterlacer() ) else: - pipe += """\ - ! fakesink - """ + pipeline += """ + fakesink + """ - if self.internal_audio_channels(): - pipe += """ + if self.has_audio: + pipeline += """ decklinkaudiosrc - name=decklinkaudiosrc-{name} + {channels} device-number={device} connection={conn} - channels={channels} - """.format(name=self.name, - device=self.device, - conn=self.aconn, - channels=self.internal_audio_channels()) + {output} + """.format( + channels="channels={}".format(self.required_input_channels) + if self.required_input_channels > 2 else + "", + device=self.device, + conn=self.aconn, + output="name=aout" + if self.fallback_default else + "! deinterleave name=aout", + ) - return pipe + for audiostream, mapping in self.audiostream_map.items(): + left, right = self._parse_audiostream_mapping(mapping) + if right is not None: + self.log.info( + "mapping decklink input-channels {left} and {right}" + "as left and right to output-stream {audiostream}" + .format(left=left, + right=right, + audiostream=audiostream)) + + pipeline += """ + interleave name=i{audiostream} + + aout.src_{left} ! queue ! i{audiostream}.sink_0 + aout.src_{right} ! queue ! i{audiostream}.sink_1 + """.format( + left=left, + right=right, + audiostream=audiostream + ) + else: + self.log.info( + "mapping decklink input-channel {channel} " + "as left and right to output-stream {audiostream}" + .format(channel=left, + audiostream=audiostream)) + + pipeline += """ + interleave name=i{audiostream} + aout.src_{channel} ! tee name=t{audiostream} + + t{audiostream}. ! queue ! i{audiostream}.sink_0 + t{audiostream}. ! queue ! i{audiostream}.sink_1 + """.format( + channel=left, + audiostream=audiostream + ) + + self.build_pipeline(pipeline) + self.pipeline.set_state(Gst.State.PLAYING) + + def build_deinterlacer(self): + deinterlacer = super().build_deinterlacer() + if deinterlacer != '': + deinterlacer += ' !' + + return deinterlacer + + def build_audioport(self, audiostream): + if self.fallback_default and audiostream == 0: + return "aout." - def build_audioport(self): - return 'decklinkaudiosrc-{name}.'.format(name=self.name) + if audiostream in self.audiostream_map: + return 'i{}.'.format(audiostream) def build_videoport(self): - return 'vout-{}.'.format(self.name) + return 'vout.' - def get_nosignal_text(self): - return super().get_nosignal_text() + "/BM%d" % self.device + def restart(self): + self.pipeline.set_state(Gst.State.NULL) + self.launch_pipeline() diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/filesource.py voctomix-1.3+git20200102/voctocore/lib/sources/filesource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/filesource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/filesource.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re - -import os - -from gi.repository import Gst - -from lib.config import Config -from lib.sources.avsource import AVSource - -class FileSource(AVSource): - timer_resolution = 0.5 - - def __init__(self, name, has_audio=True, has_video=True, - force_num_streams=None): - self.location = Config.getLocation(name) - self.audio_file = False - (_, ext) = os.path.splitext(self.location) - if ext in ['.mp2','.mp3']: - assert not has_video - self.audio_file=True - - super().__init__('FileSource', name, has_audio, has_video, show_no_signal=False) - self.loop = Config.getLoop(name) - self.build_pipeline() - - def __str__(self): - return 'FileSource[{name}] displaying {location}'.format( - name=self.name, - location=self.location - ) - - def port(self): - return os.path.basename(self.location) - - def num_connections(self): - return 1 - - def video_channels(self): - return 1 - - def build_source(self): - source = """ - multifilesrc - location={location} - loop={loop}""".format( - loop=self.loop, - location=self.location - ) - if not self.audio_file: - source += """ - ! tsdemux - """ - source += """ - name=file-{name} - """.format(name=self.name) - - return source - - def build_videoport(self): - return """ - file-{name}. - ! mpegvideoparse - ! mpeg2dec - ! videoconvert - ! videorate - ! videoscale - """.format(name=self.name) - - def build_audioport(self): - return """ - file-{name}. - ! mpegaudioparse - ! mpg123audiodec - ! audioconvert - ! audioresample - """.format(name=self.name) \ No newline at end of file diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/imgvsource.py voctomix-1.3+git20200102/voctocore/lib/sources/imgvsource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/imgvsource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/imgvsource.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,7 +1,4 @@ -#!/usr/bin/env python3 import logging -import re -import os from gi.repository import Gst @@ -10,10 +7,17 @@ class ImgVSource(AVSource): - def __init__(self, name): - super().__init__('ImgVSource', name, False, True) - self.imguri = Config.getImageURI(name) - self.build_pipeline() + def __init__(self, name, outputs=None, has_audio=False, has_video=True): + self.log = logging.getLogger('ImgVSource[{}]'.format(name)) + super().__init__(name, outputs, False, has_video) + + if has_audio: + self.log.warning("Audio requested from video-only source") + + section = 'source.{}'.format(name) + self.imguri = Config.get(section, 'imguri') + + self.launch_pipeline() def __str__(self): return 'ImgVSource[{name}] displaying {uri}'.format( @@ -21,32 +25,24 @@ uri=self.imguri ) - def port(self): - m = re.search('.*/([^/]*)', self.imguri) - return self.imguri - - def num_connections(self): - return 1 - - def video_channels(self): - return 1 - - def build_source(self): - return """ - uridecodebin - name=imgvsrc-{name} - uri={uri} - ! videoconvert - ! videoscale - ! imagefreeze - name=img-{name} -""".format( - name=self.name, + def launch_pipeline(self): + pipeline = """ + uridecodebin uri={uri} ! + videoconvert ! + videoscale ! + imagefreeze name=img + """.format( uri=self.imguri ) + self.build_pipeline(pipeline) + self.pipeline.set_state(Gst.State.PLAYING) + + def build_audioport(self, audiostream): + raise NotImplementedError( + 'build_audioport not implemented for this source') def build_videoport(self): - return "img-{name}.".format(name=self.name) + return 'img.' def restart(self): self.pipeline.set_state(Gst.State.NULL) diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/__init__.py voctomix-1.3+git20200102/voctocore/lib/sources/__init__.py --- voctomix-1.3+git20200101/voctocore/lib/sources/__init__.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/__init__.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,44 +1,38 @@ import logging +from lib.config import Config +from lib.sources.decklinkavsource import DeckLinkAVSource +from lib.sources.imgvsource import ImgVSource +from lib.sources.tcpavsource import TCPAVSource + log = logging.getLogger('AVSourceManager') sources = {} -def spawn_source(name, port, has_audio=True, has_video=True): - - from lib.config import Config - from lib.sources.decklinkavsource import DeckLinkAVSource - from lib.sources.imgvsource import ImgVSource - from lib.sources.tcpavsource import TCPAVSource - from lib.sources.testsource import TestSource - from lib.sources.filesource import FileSource - from lib.sources.v4l2source import V4l2AVSource +def spawn_source(name, port, outputs=None, + has_audio=True, has_video=True, + force_num_streams=None): - kind = Config.getSourceKind(name) + section = 'source.{}'.format(name) + kind = Config.get(section, 'kind', fallback='tcp') if kind == 'img': - sources[name] = ImgVSource(name) - elif kind == 'decklink': - sources[name] = DeckLinkAVSource(name, has_audio, has_video) - elif kind == 'file': - sources[name] = FileSource(name, has_audio, has_video) - elif kind == 'tcp': - sources[name] = TCPAVSource(name, port, has_audio, has_video) - elif kind == 'v4l2': - sources[name] = V4l2AVSource(name) - else: - if kind != 'test': - log.warning( - 'Unknown value "%s" in attribute "kind" in definition of source %s (see section [source.%s] in configuration). Falling back to kind "test".', kind, name, name) - sources[name] = TestSource(name, has_audio, has_video) + sources[name] = ImgVSource(name, outputs, has_audio, has_video) + return sources[name] + if kind == 'decklink': + sources[name] = DeckLinkAVSource(name, outputs, has_audio, has_video) + return sources[name] + + if kind != 'tcp': + log.warning('Unknown source kind "%s", defaulting to "tcp"', kind) + + sources[name] = TCPAVSource(name, port, outputs, + has_audio, has_video, + force_num_streams) return sources[name] -def kind_has_audio(source): - return source in ["decklink", "tcp", "test"] - - def restart_source(name): - assert False, "restart_source() not implemented" + sources[name].restart() diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/tcpavsource.py voctomix-1.3+git20200102/voctocore/lib/sources/tcpavsource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/tcpavsource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/tcpavsource.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,112 +1,74 @@ -#!/usr/bin/env python3 import logging -from gi.repository import Gst, GObject -import socket +from gi.repository import Gst from lib.config import Config from lib.sources.avsource import AVSource +from lib.tcpsingleconnection import TCPSingleConnection ALL_AUDIO_CAPS = Gst.Caps.from_string('audio/x-raw') ALL_VIDEO_CAPS = Gst.Caps.from_string('video/x-raw') -class TCPAVSource(AVSource): - timer_resolution = 0.5 - - def __init__(self, name, listen_port, has_audio=True, has_video=True, +class TCPAVSource(AVSource, TCPSingleConnection): + def __init__(self, name, port, outputs=None, + has_audio=True, has_video=True, force_num_streams=None): - super().__init__('TCPAVSource', name, has_audio, has_video, - force_num_streams, show_no_signal=True) - - self.listen_port = listen_port - self.tcpsrc = None - self.audio_caps = Gst.Caps.from_string(Config.getAudioCaps()) - self.video_caps = Gst.Caps.from_string(Config.getVideoCaps()) - self.build_pipeline() - self.connected = False - - def port(self): - return"%s:%d" % (socket.gethostname(), self.listen_port) - - def num_connections(self): - if self.connected: - return 1 - else: - return 0 - - def attach(self, pipeline): - super().attach(pipeline) - self.log.debug("connecting to pads") - - # create probe at static tcpserversrc.src to get EOS and trigger a restart - self.tcpsrc = pipeline.get_by_name( - 'tcpsrc-{name}'.format(name=self.name)) - self.tcpsrc.get_static_pad("src").add_probe( - Gst.PadProbeType.EVENT_DOWNSTREAM | Gst.PadProbeType.BLOCK, self.on_pad_event) - - # subscribe to creation of dynamic pads in matroskademux - self.demux = pipeline.get_by_name( - 'demux-{name}'.format(name=self.name)) - self.demux.connect('pad-added', self.on_pad_added) - - # remember queues the demux is connected to to reconnect them when necessary - self.queue_audio = pipeline.get_by_name( - 'queue-tcpsrc-audio-{name}'.format(name=self.name)) - self.queue_video = pipeline.get_by_name( - 'queue-tcpsrc-video-{name}'.format(name=self.name)) - - self.src = pipeline.get_by_name('src-{name}'.format(name=self.name)) + self.log = logging.getLogger('TCPAVSource[{}]'.format(name)) + AVSource.__init__(self, name, outputs, + has_audio, has_video, + force_num_streams) + TCPSingleConnection.__init__(self, port) def __str__(self): - return 'TCPAVSource[{name}] listening at {listen} ({port})'.format( + return 'TCPAVSource[{name}] on tcp-port {port}'.format( name=self.name, - listen=self.port(), - port=self.tcpsrc.get_property( - "current-port") if self.tcpsrc else "" + port=self.boundSocket.getsockname()[1] ) - def build_source(self): + def on_accepted(self, conn, addr): deinterlacer = self.build_deinterlacer() - pipe = """ - tcpserversrc - name=tcpsrc-{name} - do-timestamp=TRUE - port={port} - ! demux-{name}. - - matroskademux - name=demux-{name} - """.format( - name=self.name, - port=self.listen_port + pipeline = """ + fdsrc fd={fd} blocksize=1048576 ! + queue ! + matroskademux name=demux + """.format( + fd=conn.fileno() ) if deinterlacer: - pipe += """ - demux-{name}.video_0 - ! queue - max-size-time=3000000000 - name=queue-tcpsrc-video-{name} - ! video/x-raw - ! {deinterlacer}""".format( - name=self.name, - deinterlacer=deinterlacer + pipeline += """ + demux. ! + video/x-raw ! + {deinterlacer} + """.format( + deinterlacer=self.build_deinterlacer() ) - return pipe + self.build_pipeline(pipeline) + + else: + self.build_pipeline(pipeline) + + self.audio_caps = Gst.Caps.from_string(Config.get('mix', 'audiocaps')) + self.video_caps = Gst.Caps.from_string(Config.get('mix', 'videocaps')) + + demux = self.pipeline.get_by_name('demux') + demux.connect('pad-added', self.on_pad_added) + + self.pipeline.set_state(Gst.State.PLAYING) def build_deinterlacer(self): deinterlacer = super().build_deinterlacer() - if deinterlacer: - return deinterlacer + ' name=deinter-{name}'.format(name=self.name) - else: - return None - def on_pad_added(self, demux, pad): - caps = pad.query_caps(None) - self.log.debug('demuxer added pad w/ caps: %s', caps.to_string()) + if deinterlacer != '': + deinterlacer += ' name=deinter' + + return deinterlacer - if self.has_audio and caps.can_intersect(ALL_AUDIO_CAPS): + def on_pad_added(self, demux, src_pad): + caps = src_pad.query_caps(None) + self.log.debug('demuxer added pad w/ caps: %s', caps.to_string()) + if caps.can_intersect(ALL_AUDIO_CAPS): self.log.debug('new demuxer-pad is an audio-pad, ' 'testing against configured audio-caps') if not caps.can_intersect(self.audio_caps): @@ -117,7 +79,7 @@ self.log.warning(' configured caps: %s', self.audio_caps.to_string()) - elif self.has_video and caps.can_intersect(ALL_VIDEO_CAPS): + elif caps.can_intersect(ALL_VIDEO_CAPS): self.log.debug('new demuxer-pad is a video-pad, ' 'testing against configured video-caps') if not caps.can_intersect(self.video_caps): @@ -130,47 +92,40 @@ self.test_and_warn_interlace_mode(caps) - # relink demux with following audio and video queues - if not pad.is_linked(): - self.demux.link(self.queue_audio) - self.demux.link(self.queue_video) - self.connected = True - - def on_pad_event(self, pad, info): - if info.get_event().type == Gst.EventType.EOS: - self.log.warning('scheduling source restart') - self.connected = False - GObject.idle_add(self.restart) - - return Gst.PadProbeReturn.PASS + def on_eos(self, bus, message): + super().on_eos(bus, message) + if self.currentConnection is not None: + self.disconnect() + + def on_error(self, bus, message): + super().on_error(bus, message) + if self.currentConnection is not None: + self.disconnect() + + def disconnect(self): + self.pipeline.set_state(Gst.State.NULL) + self.pipeline = None + self.close_connection() def restart(self): - self.log.debug('restarting source \'%s\'', self.name) - self.tcpsrc.set_state(Gst.State.READY) - self.demux.set_state(Gst.State.READY) - self.demux.set_state(Gst.State.PLAYING) - self.tcpsrc.set_state(Gst.State.PLAYING) - - def build_audioport(self): - return """ - ! queue - max-size-time=3000000000 - name=queue-{name}.audio""".format(name=self.name) + if self.currentConnection is not None: + self.disconnect() + + def build_audioport(self, audiostream): + return 'demux.audio_{}'.format(audiostream) def build_videoport(self): deinterlacer = self.build_deinterlacer() if deinterlacer: - return """ - deinter-{name}.""".format(name=self.name) + return 'deinter.' else: - return """ - demux-{name}.""".format(name=self.name) + return 'demux.' def test_and_warn_interlace_mode(self, caps): interlace_mode = caps.get_structure(0).get_string('interlace-mode') - source_mode = Config.getSourceScan(self.name) + deinterlace_config = self.get_deinterlace_config() - if interlace_mode == 'mixed' and source_mode == 'progressive': + if interlace_mode == 'mixed' and deinterlace_config == 'no': self.log.warning( 'your source sent an interlace_mode-flag in the matroska-' 'container, specifying the source-video-stream is of ' diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/testsource.py voctomix-1.3+git20200102/voctocore/lib/sources/testsource.py --- voctomix-1.3+git20200101/voctocore/lib/sources/testsource.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/testsource.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -import logging - -from configparser import NoOptionError -from gi.repository import Gst - -from lib.config import Config -from lib.sources.avsource import AVSource - - -class TestSource(AVSource): - def __init__(self, name, has_audio=True, has_video=True, - force_num_streams=None): - super().__init__('TestSource', name, has_audio, has_video, - force_num_streams) - - self.name = name - self.pattern = Config.getTestPattern(name) - self.wave = Config.getTestWave(name) - self.build_pipeline() - - def port(self): - if self.has_video: - if self.internal_audio_channels(): - return "(AV:{}+{})".format(self.pattern, self.wave) - else: - return "(V:{})".format(self.pattern) - else: - if self.internal_audio_channels(): - return "(A:{})".format(self.wave) - return "Test" - - def num_connections(self): - return 1 - - def __str__(self): - return 'TestSource[{name}] ({pattern}, {wave})'.format( - name=self.name, - pattern=self.pattern, - wave=self.wave - ) - - def build_audioport(self): - # a volume of 0.126 is ~18dBFS - return """audiotestsrc - name=audiotestsrc-{name} - do-timestamp=true - freq=1000 - volume=0.126 - wave={wave} - is-live=true""".format( - name=self.name, - wave=self.wave, - ) - - def build_videoport(self): - return """videotestsrc - name=videotestsrc-{name} - do-timestamp=true - pattern={pattern} - is-live=true""".format( - name=self.name, - pattern=self.pattern - ) diff -Nru voctomix-1.3+git20200101/voctocore/lib/sources/v4l2source.py voctomix-1.3+git20200102/voctocore/lib/sources/v4l2source.py --- voctomix-1.3+git20200101/voctocore/lib/sources/v4l2source.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/sources/v4l2source.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re - -from gi.repository import Gst, GLib - -from lib.config import Config -from lib.sources.avsource import AVSource - - -class V4l2AVSource(AVSource): - - timer_resolution = 0.5 - - def __init__(self, name): - super().__init__('V4l2Source', name, False, True, show_no_signal=False) - - self.device = Config.getV4l2Device(name) - self.width = Config.getV4l2Width(name) - self.height = Config.getV4l2Height(name) - self.framerate = Config.getV4l2Framerate(name) - self.format = Config.getV4l2Format(name) - self.name = name - self.signalPad = None - - self.build_pipeline() - - def port(self): - return "V4l2 device {}".format(self.device) - - def attach(self, pipeline): - super().attach(pipeline) - self.signalPad = pipeline.get_by_name( - 'v4l2videosrc-{}'.format(self.name)) - GLib.timeout_add(self.timer_resolution * 1000, self.do_timeout) - - def num_connections(self): - return 1 if self.signalPad and self.signalPad.get_property('signal') else 0 - - def __str__(self): - return 'V4l2AVSource[{name}] reading device {device}'.format( - name=self.name, - device=self.device - ) - - def get_valid_channel_numbers(self): - return (0) - - def build_source(self): - pipe = """ - v4l2src - device={device} - """.format(device=self.device) - - pipe += """\ - ! video/x-raw,width={width},height={height},format={format},framerate={framerate} - """.format(width=self.width, - height=self.height, - format=self.format, - framerate=self.framerate) - - if self.build_deinterlacer(): - pipe += """\ - ! {deinterlacer} - """.format(deinterlacer=self.build_deinterlacer()) - - pipe += """\ - ! videoconvert - ! videoscale - ! videorate - name=vout-{name} - """.format(name=self.name) - - return pipe - - def build_videoport(self): - return 'vout-{}.'.format(self.name) diff -Nru voctomix-1.3+git20200101/voctocore/lib/streamblanker.py voctomix-1.3+git20200102/voctocore/lib/streamblanker.py --- voctomix-1.3+git20200101/voctocore/lib/streamblanker.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/streamblanker.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,201 @@ +import logging + +from gi.repository import Gst + +from lib.config import Config +from lib.clock import Clock + + +class StreamBlanker(object): + log = logging.getLogger('StreamBlanker') + + def __init__(self): + self.acaps = Config.get('mix', 'audiocaps') + self.vcaps = Config.get('mix', 'videocaps') + + self.names = Config.getlist('stream-blanker', 'sources') + self.log.info('Configuring StreamBlanker video %u Sources', + len(self.names)) + + self.volume = Config.getfloat('stream-blanker', 'volume') + + # Videomixer + pipeline = """ + compositor name=vmix ! + {vcaps} ! + queue ! + intervideosink channel=video_stream-blanker_out + """.format( + vcaps=self.vcaps, + ) + + # Source from the Main-Mix + pipeline += """ + intervideosrc channel=video_mix_stream-blanker ! + {vcaps} ! + vmix. + """.format( + vcaps=self.vcaps, + ) + + if Config.has_option('mix', 'slides_source_name'): + pipeline += """ + compositor name=vmix-slides ! + {vcaps} ! + queue ! + intervideosink channel=video_slides_stream-blanker_out + """.format( + vcaps=self.vcaps, + ) + + pipeline += """ + intervideosrc channel=video_slides_stream-blanker ! + {vcaps} ! + vmix-slides. + """.format( + vcaps=self.vcaps, + ) + + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + # Audiomixer + pipeline += """ + audiomixer name=amix_{audiostream} ! + {acaps} ! + queue ! + tee name=amix_{audiostream}-tee ! + queue ! + interaudiosink + channel=audio_stream-blanker_out_stream{audiostream} + """.format( + acaps=self.acaps, + audiostream=audiostream, + ) + + if Config.has_option('mix', 'slides_source_name'): + pipeline += """ + amix_{audiostream}-tee. ! + queue ! + interaudiosink + channel=audio_slides_stream-blanker_out_stream{audiostream} + """.format( + audiostream=audiostream, + ) + + # Source from the Main-Mix + pipeline += """ + interaudiosrc + channel=audio_mix_stream{audiostream}_stream-blanker ! + {acaps} ! + amix_{audiostream}. + """.format( + acaps=self.acaps, + audiostream=audiostream, + ) + + pipeline += "\n\n" + + # Source from the Blank-Audio into a tee + pipeline += """ + interaudiosrc channel=audio_stream-blanker_stream0 ! + {acaps} ! + queue ! + tee name=atee + """.format( + acaps=self.acaps, + ) + + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + # Source from the Blank-Audio-Tee into the Audiomixer + pipeline += """ + atee. ! queue ! amix_{audiostream}. + """.format( + audiostream=audiostream, + ) + + pipeline += "\n\n" + + for name in self.names: + # Source from the named Blank-Video + pipeline += """ + intervideosrc channel=video_stream-blanker-{name} ! + {vcaps} ! + queue ! + tee name=video_stream-blanker-tee-{name} ! + queue ! + vmix. + """.format( + name=name, + vcaps=self.vcaps, + ) + + if Config.has_option('mix', 'slides_source_name'): + pipeline += """ + video_stream-blanker-tee-{name}. ! + queue ! + vmix-slides. + """.format( + name=name, + ) + + self.log.debug('Creating Mixing-Pipeline:\n%s', pipeline) + self.mixingPipeline = Gst.parse_launch(pipeline) + self.mixingPipeline.use_clock(Clock) + + self.log.debug('Binding Error & End-of-Stream-Signal ' + 'on Mixing-Pipeline') + self.mixingPipeline.bus.add_signal_watch() + self.mixingPipeline.bus.connect("message::eos", self.on_eos) + self.mixingPipeline.bus.connect("message::error", self.on_error) + + self.log.debug('Initializing Mixer-State') + self.blankSource = 0 if len(self.names) > 0 else None + self.applyMixerState() + + self.log.debug('Launching Mixing-Pipeline') + self.mixingPipeline.set_state(Gst.State.PLAYING) + + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Mixing-Pipeline') + + def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Mixing-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) + + def applyMixerState(self): + self.applyMixerStateAudio() + self.applyMixerStateVideo('vmix') + if Config.has_option('mix', 'slides_source_name'): + self.applyMixerStateVideo('vmix-slides') + + def applyMixerStateAudio(self): + is_blanked = self.blankSource is not None + + for audiostream in range(0, Config.getint('mix', 'audiostreams')): + mixer = self.mixingPipeline.get_by_name( + 'amix_{}'.format(audiostream)) + mixpad = mixer.get_static_pad('sink_0') + blankpad = mixer.get_static_pad('sink_1') + + mixpad.set_property( + 'volume', + 0.0 if is_blanked else 1.0) + + blankpad.set_property( + 'volume', + self.volume if is_blanked else 0.0) + + def applyMixerStateVideo(self, mixername): + mixpad = (self.mixingPipeline.get_by_name(mixername) + .get_static_pad('sink_0')) + mixpad.set_property('alpha', int(self.blankSource is None)) + + for idx, name in enumerate(self.names): + blankpad = (self.mixingPipeline + .get_by_name(mixername) + .get_static_pad('sink_%u' % (idx + 1))) + blankpad.set_property('alpha', int(self.blankSource == idx)) + + def setBlankSource(self, source): + self.blankSource = source + self.applyMixerState() diff -Nru voctomix-1.3+git20200101/voctocore/lib/tcpmulticonnection.py voctomix-1.3+git20200102/voctocore/lib/tcpmulticonnection.py --- voctomix-1.3+git20200101/voctocore/lib/tcpmulticonnection.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/tcpmulticonnection.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,6 +1,5 @@ import logging import socket -import sys from queue import Queue from abc import ABCMeta, abstractmethod from gi.repository import GObject @@ -12,46 +11,29 @@ if not hasattr(self, 'log'): self.log = logging.getLogger('TCPMultiConnection') - self._port = None + self.boundSocket = None + self.currentConnections = dict() - try: - self.boundSocket = None - self.currentConnections = dict() - - self.log.debug('Binding to Source-Socket on [::]:%u', port) - self.boundSocket = socket.socket(socket.AF_INET6) - self.boundSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.boundSocket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, - False) - self.boundSocket.bind(('::', port)) - self.boundSocket.listen(1) - self._port = port - - self.log.debug('Setting GObject io-watch on Socket') - GObject.io_add_watch(self.boundSocket, GObject.IO_IN, self.on_connect) - except OSError: - self.log.error("Can not open listening port %d because it is already in use. Is another instance of voctocore running already?" % port) - sys.exit(-1) + self.log.debug('Binding to Source-Socket on [::]:%u', port) + self.boundSocket = socket.socket(socket.AF_INET6) + self.boundSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.boundSocket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, + False) + self.boundSocket.bind(('::', port)) + self.boundSocket.listen(1) - - def port(self): - return "%s:%d" % (socket.gethostname(), self._port if self._port else 0) - - def num_connections(self): - return len(self.currentConnections) - - def is_input(self): - return False + self.log.debug('Setting GObject io-watch on Socket') + GObject.io_add_watch(self.boundSocket, GObject.IO_IN, self.on_connect) def on_connect(self, sock, *args): conn, addr = sock.accept() conn.setblocking(False) - self.log.info("Incoming Connection from [%s]:%u (fd=%u)", + self.log.info("Incomming Connection from [%s]:%u (fd=%u)", addr[0], addr[1], conn.fileno()) self.currentConnections[conn] = Queue() - self.log.info('Now %u Receiver(s) connected', + self.log.info('Now %u Receiver connected', len(self.currentConnections)) self.on_accepted(conn, addr) diff -Nru voctomix-1.3+git20200101/voctocore/lib/tcpsingleconnection.py voctomix-1.3+git20200102/voctocore/lib/tcpsingleconnection.py --- voctomix-1.3+git20200101/voctocore/lib/tcpsingleconnection.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/tcpsingleconnection.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,58 @@ +import logging +import socket +import time +from abc import ABCMeta, abstractmethod +from gi.repository import GObject + + +class TCPSingleConnection(object, metaclass=ABCMeta): + + def __init__(self, port): + if not hasattr(self, 'log'): + self.log = logging.getLogger('TCPSingleConnection') + + self.log.debug('Binding to Source-Socket on [::]:%u', port) + self.boundSocket = socket.socket(socket.AF_INET6) + self.boundSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.boundSocket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, + False) + self.boundSocket.bind(('::', port)) + self.boundSocket.listen(1) + + self.currentConnection = None + + self.log.debug('Setting GObject io-watch on Socket') + GObject.io_add_watch(self.boundSocket, GObject.IO_IN, self.on_connect) + + def on_connect(self, sock, *args): + conn, addr = sock.accept() + self.log.info('Incomming Connection from %s', addr) + + if self.currentConnection is not None: + self.log.warning('Another Source is already connected, ' + 'closing existing pipeline') + self.disconnect() + time.sleep(1) + + self.on_accepted(conn, addr) + self.currentConnection = conn + + return True + + def close_connection(self): + if self.currentConnection: + self.currentConnection.close() + self.currentConnection = None + self.log.info('Connection closed') + + @abstractmethod + def on_accepted(self, conn, addr): + raise NotImplementedError( + "child classes of TCPSingleConnection must implement on_accepted()" + ) + + @abstractmethod + def disconnect(self): + raise NotImplementedError( + "child classes of TCPSingleConnection must implement disconnect()" + ) diff -Nru voctomix-1.3+git20200101/voctocore/lib/videomix.py voctomix-1.3+git20200102/voctocore/lib/videomix.py --- voctomix-1.3+git20200101/voctocore/lib/videomix.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/lib/videomix.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,393 +1,485 @@ -#!/usr/bin/env python3 import logging - from configparser import NoOptionError from enum import Enum, unique -import gi -gi.require_version('GstController', '1.0') + from gi.repository import Gst + from lib.config import Config -from vocto.transitions import Composites, Transitions, Frame, fade_alpha -from lib.scene import Scene -from lib.overlay import Overlay -from lib.args import Args +from lib.clock import Clock + + +@unique +class CompositeModes(Enum): + fullscreen = 0 + side_by_side_equal = 1 + side_by_side_preview = 2 + picture_in_picture = 3 + + +class PadState(object): + def __init__(self): + self.reset() + + def reset(self): + self.alpha = 1.0 + + self.zorder = 1 -from vocto.composite_commands import CompositeCommand + self.xpos = 0 + self.ypos = 0 + + self.width = 0 + self.height = 0 + + self.croptop = 0 + self.cropleft = 0 + self.cropbottom = 0 + self.cropright = 0 class VideoMix(object): log = logging.getLogger('VideoMix') def __init__(self): - # read sources from confg file - self.bgSources = Config.getBackgroundSources() - self.sources = Config.getSources() - self.log.info('Configuring mixer for %u source(s) and %u background source(s)', len(self.sources), len(self.bgSources)) - - # load composites from config - self.log.info("Reading transitions configuration...") - self.composites = Config.getComposites() - - # load transitions from configuration - self.transitions = Config.getTransitions(self.composites) - self.scene = None - self.bgScene = None - self.overlay = None - - Config.getAudioStreams() - - # build GStreamer mixing pipeline descriptor - self.bin = "" if Args.no_bins else """ - bin.( - name=VideoMix - """ - self.bin += """ - compositor - name=videomixer - """ - if Config.hasOverlay(): - self.bin += """\ - ! queue - max-size-time=3000000000 - name=queue-overlay - ! gdkpixbufoverlay - name=overlay - overlay-width={width} - overlay-height={height} - """.format( - width=Config.getVideoResolution()[0], - height=Config.getVideoResolution()[1] - ) - if Config.getOverlayFile(): - self.bin += """\ - location={overlay} - alpha=1.0 - """.format(overlay=Config.getOverlayFilePath(Config.getOverlayFile())) - else: - self.log.info("No initial overlay source configured.") + self.caps = Config.get('mix', 'videocaps') - self.bin += """\ - ! identity - name=sig - ! {vcaps} - ! queue - max-size-time=3000000000 - ! tee - name=video-mix - """.format( - vcaps=Config.getVideoCaps() + self.names = Config.getlist('mix', 'sources') + self.log.info('Configuring Mixer for %u Sources', len(self.names)) + + pipeline = """ + compositor name=mix ! + {caps} ! + identity name=sig ! + queue ! + tee name=tee + + intervideosrc channel=video_background ! + {caps} ! + mix. + + tee. ! queue ! intervideosink channel=video_mix_out + """.format( + caps=self.caps ) - for idx, background in enumerate(self.bgSources): - self.bin += """ - video-{name}. - ! queue - max-size-time=3000000000 - name=queue-video-{name} - ! videomixer. - """.format(name=background) - - for idx, name in enumerate(self.sources): - self.bin += """ - video-{name}. - ! queue - max-size-time=3000000000 - name=queue-cropper-{name} - ! videobox - name=cropper-{name} - ! queue - max-size-time=3000000000 - name=queue-videomixer-{name} - ! videomixer. - """.format( + if Config.getboolean('previews', 'enabled'): + pipeline += """ + tee. ! queue ! intervideosink channel=video_mix_preview + """ + + if Config.getboolean('stream-blanker', 'enabled'): + pipeline += """ + tee. ! queue ! intervideosink channel=video_mix_stream-blanker + """ + + for idx, name in enumerate(self.names): + pipeline += """ + intervideosrc channel=video_{name}_mixer ! + {caps} ! + videocrop name=video_{idx}_cropper ! + mix. + """.format( name=name, + caps=self.caps, idx=idx ) - self.bin += "" if Args.no_bins else """) - """ + self.log.debug('Creating Mixing-Pipeline:\n%s', pipeline) + self.mixingPipeline = Gst.parse_launch(pipeline) + self.mixingPipeline.use_clock(Clock) + + self.log.debug('Binding Error & End-of-Stream-Signal ' + 'on Mixing-Pipeline') + self.mixingPipeline.bus.add_signal_watch() + self.mixingPipeline.bus.connect("message::eos", self.on_eos) + self.mixingPipeline.bus.connect("message::error", self.on_error) - def attach(self, pipeline): self.log.debug('Binding Handoff-Handler for ' 'Synchronus mixer manipulation') - self.pipeline = pipeline - sig = pipeline.get_by_name('sig') + sig = self.mixingPipeline.get_by_name('sig') sig.connect('handoff', self.on_handoff) + self.padStateDirty = False + self.padState = list() + for idx, name in enumerate(self.names): + self.padState.append(PadState()) + self.log.debug('Initializing Mixer-State') - # initialize pipeline bindings for all sources - self.bgScene = Scene(self.bgSources, pipeline, self.transitions.fps, 0, cropping=False) - self.scene = Scene(self.sources, pipeline, self.transitions.fps, len(self.bgSources)) - self.compositeMode = None - self.sourceA = None - self.sourceB = None - self.setCompositeEx(Composites.targets(self.composites)[ - 0].name, self.sources[0], self.sources[1]) - - if Config.hasOverlay(): - self.overlay = Overlay( - pipeline, Config.getOverlayFile(), Config.getOverlayBlendTime()) - - def __str__(self): - return 'VideoMix' - - def getPlayTime(self): - # get play time from mixing pipeline or assume zero - return self.pipeline.get_pipeline_clock().get_time() - \ - self.pipeline.get_base_time() + self.compositeMode = CompositeModes.fullscreen + self.sourceA = 0 + self.sourceB = 1 + self.recalculateMixerState() + self.applyMixerState() + + bgMixerpad = (self.mixingPipeline.get_by_name('mix') + .get_static_pad('sink_0')) + bgMixerpad.set_property('zorder', 0) + + self.log.debug('Launching Mixing-Pipeline') + self.mixingPipeline.set_state(Gst.State.PLAYING) + + def getInputVideoSize(self): + caps = Gst.Caps.from_string(self.caps) + struct = caps.get_structure(0) + _, width = struct.get_int('width') + _, height = struct.get_int('height') + + return width, height + + def recalculateMixerState(self): + if self.compositeMode == CompositeModes.fullscreen: + self.recalculateMixerStateFullscreen() + + elif self.compositeMode == CompositeModes.side_by_side_equal: + self.recalculateMixerStateSideBySideEqual() + + elif self.compositeMode == CompositeModes.side_by_side_preview: + self.recalculateMixerStateSideBySidePreview() + + elif self.compositeMode == CompositeModes.picture_in_picture: + self.recalculateMixerStatePictureInPicture() + + self.log.debug('Marking Pad-State as Dirty') + self.padStateDirty = True + + def recalculateMixerStateFullscreen(self): + self.log.info('Updating Mixer-State for Fullscreen-Composition') + + for idx, name in enumerate(self.names): + pad = self.padState[idx] + + pad.reset() + pad.alpha = float(idx == self.sourceA) + + def recalculateMixerStateSideBySideEqual(self): + self.log.info('Updating Mixer-State for ' + 'Side-by-side-Equal-Composition') + + width, height = self.getInputVideoSize() + self.log.debug('Video-Size parsed as %ux%u', width, height) + + try: + gutter = Config.getint('side-by-side-equal', 'gutter') + self.log.debug('Gutter configured to %u', gutter) + except NoOptionError: + gutter = int(width / 100) + self.log.debug('Gutter calculated to %u', gutter) + + try: + border = Config.getint('side-by-side-equal', 'border') + self.log.debug('border configured to %u', border) + except NoOptionError: + border = 0 + self.log.debug('border calculated to %u', border) + + targetWidth = int((width - gutter - border - border) / 2) + targetHeight = int(targetWidth / width * height) + + self.log.debug('Video-Size calculated to %ux%u', + targetWidth, targetHeight) + + xa = border + xb = width - targetWidth - border + y = int((height - targetHeight) / 2) + + try: + ya = Config.getint('side-by-side-equal', 'atop') + self.log.debug('A-Video Y-Pos configured to %u', ya) + except NoOptionError: + ya = y + self.log.debug('A-Video Y-Pos calculated to %u', ya) + + try: + yb = Config.getint('side-by-side-equal', 'btop') + self.log.debug('B-Video Y-Pos configured to %u', yb) + except NoOptionError: + yb = y + self.log.debug('B-Video Y-Pos calculated to %u', yb) + + for idx, name in enumerate(self.names): + pad = self.padState[idx] + pad.reset() + + pad.width = targetWidth + pad.height = targetHeight + + if idx == self.sourceA: + pad.xpos = xa + pad.ypos = ya + pad.zorder = 2 + + elif idx == self.sourceB: + pad.xpos = xb + pad.ypos = yb + pad.zorder = 1 - def on_handoff(self, object, buffer): - playTime = self.getPlayTime() - if self.bgScene and self.bgScene.dirty: - # push background scene to gstreamer - self.log.debug('Applying new background at %d ms', - playTime / Gst.MSECOND) - self.bgScene.push(playTime) - if self.scene and self.scene.dirty: - # push scene to gstreamer - self.log.debug('Applying new mix at %d ms', - playTime / Gst.MSECOND) - self.scene.push(playTime) - - def setCompositeEx(self, newCompositeName=None, newA=None, newB=None, useTransitions=False, dry=False): - # expect strings or None as parameters - assert not newCompositeName or type(newCompositeName) == str - assert not newA or type(newA) == str - assert not newB or type(newB) == str - - # get current composite - if not self.compositeMode: - curCompositeName = None - self.log.info("Request composite %s(%s,%s)", - newCompositeName, newA, newB) - else: - curCompositeName = self.compositeMode - curA = self.sourceA - curB = self.sourceB - self.log.info("Request composite change from %s(%s,%s) to %s(%s,%s)", - curCompositeName, curA, curB, newCompositeName, newA, newB) - - # check if there is any None parameter and fill it up with - # reasonable value from the current scene - if curCompositeName and not (newCompositeName and newA and newB): - # use current state if not defined by parameter - if not newCompositeName: - newCompositeName = curCompositeName - if not newA: - newA = curA if newB != curA else curB - if not newB: - newB = curA if newA == curB else curB - self.log.debug("Completing wildcarded composite to %s(%s,%s)", - newCompositeName, newA, newB) - # post condition: we should have all parameters now - assert newA != newB - assert newCompositeName and newA and newB - - # fetch composites - curComposite = self.composites[curCompositeName] if curCompositeName else None - newComposite = self.composites[newCompositeName] - - # if new scene is complete - if newComposite and newA in self.sources and newB in self.sources: - self.log.debug("New composite shall be %s(%s,%s)", - newComposite.name, newA, newB) - # try to find a matching transition from current to new scene - transition = None - targetA, targetB = newA, newB - if useTransitions: - if curComposite: - old = (curA,curB,newA,newB) - - # check if whe have a three-channel scenario - if len(set(old)) == 3: - self.log.debug("Current composite includes three different frames: (%s,%s) -> (%s,%s)", *old) - # check if current composite hides B - if curComposite.single(): - self.log.debug("Current composite hides channel B so we can secretly change it.") - # check for (A,B) -> (A,C) - if curA == newA: - # change into (A,C) -> (A,C) - curB = newB - # check for (A,B) -> (C,A) - elif curA == newB: - # change into (A,C) -> (C,A) - curB = newA - # check another case where new composite also hides B - elif newComposite.single(): - self.log.debug("New composite also hides channel B so we can secretly change it.") - # change (A,B) -> (C,B) into (A,C) -> (C,A) - newB = curA - curB = newA - elif newComposite.single(): - # check for (A,B) -> (A,C) - if curA == newA: - newB = curB - # check for (A,B) -> (B,C) - if curB == newA: - newB = curA - - # check if whe have a four-channel scenario - if len(set(old)) == 4: - self.log.debug("Current composite includes four different frames: (%s,%s) -> (%s,%s)", *old) - # check if both composites hide channel B - if curComposite.single() and newComposite.single(): - self.log.debug("Current and new composite hide channel B so we can secretly change it.") - # change (A,B) -> (C,D) into (A,C) -> (C,A) - curB = newA - newB = curA - - # log if whe changed somtehing - if old != (curA,curB,newA,newB): - self.log.info("Changing requested transition from (%s,%s) -> (%s,%s) to (%s,%s) -> (%s,%s)", *old, curA,curB,newA,newB) - - swap = False - if (curA, curB) == (newA, newB) and curComposite != newComposite: - transition, swap = self.transitions.solve( - curComposite, newComposite, False) - elif (curA, curB) == (newB, newA): - transition, swap = self.transitions.solve( - curComposite, newComposite, True) - if not swap: - targetA, targetB = newB, newA - if transition and not dry: - self.log.warning("No transition found") - if dry: - return (newA, newB) if transition else False - # z-orders of A and B - below = 100 - above = 101 - # found transition? - if transition: - # apply found transition - self.log.debug( - "committing transition '%s' to scene", transition.name()) - self.scene.commit(targetA, transition.Az(below, above)) - self.scene.commit(targetB, transition.Bz(above, below)) else: - # apply new scene (hard cut) - self.log.debug( - "setting composite '%s' to scene", newComposite.name) - self.scene.set(targetA, newComposite.Az(below)) - self.scene.set(targetB, newComposite.Bz(above)) - # make all other sources invisible - for source in self.sources: - if source not in [targetA, targetB]: - self.log.debug("making source %s invisible", source) - self.scene.set(source, Frame(True, alpha=0, zorder=-1)) - - # get current and new background source by the composites - curBgSource = Config.getBackgroundSource(curCompositeName) - newBgSource = Config.getBackgroundSource(newCompositeName) - if curBgSource != newBgSource: - # found transition? - if transition: - # apply found transition - self.log.debug("committing background fading to scene") - # keep showing old background at z-order 0 - curBgFrame = Frame(True, zorder=0, rect=[0,0,*Config.getVideoResolution()]) - self.bgScene.set(curBgSource, curBgFrame) - # fade new background in at z-order 1 so it will cover the old one at end - newBgFrame = Frame(True, alpha=0, zorder=1, rect=[0,0,*Config.getVideoResolution()]) - self.bgScene.commit(newBgSource, fade_alpha(newBgFrame,255,transition.frames())) - else: - # apply new scene (hard cut) - self.log.debug( - "setting new background to scene") - # just switch to new background - bgFrame = Frame(True, zorder=0, rect=[0,0,*Config.getVideoResolution()]) - self.bgScene.set(newBgSource, bgFrame) - # make all other background sources invisible - for source in self.bgSources: - if source not in [curBgSource,newBgSource]: - self.log.debug("making background source %s invisible", source) - self.bgScene.set(source, Frame(True, alpha=0, zorder=-1)) - else: - # report unknown elements of the target scene - if not newComposite: - self.log.error("Unknown composite '%s'", newCompositeName) - if not newA in self.sources: - self.log.error("Unknown source '%s'", newA) - if not newB in self.sources: - self.log.error("Unknown source '%s'", newB) - - # remember scene we've set - self.compositeMode = newComposite.name - self.sourceA = newA - self.sourceB = newB - - def setComposite(self, command, useTransitions=False): - ''' parse switch to the composite described by string command ''' - # expect string as parameter - assert type(command) == str - # parse command - command = CompositeCommand.from_str(command) - self.log.debug("Setting new composite by string '%s'", command) - self.setCompositeEx(command.composite, command.A, - command.B, useTransitions) - - def testCut(self, command): - # expect string as parameter - assert type(command) == str - # parse command - command = CompositeCommand.from_str(command) - if (command.composite != self.compositeMode or command.A != self.sourceA or command.B != self.sourceB): - return command.A, command.B - else: - return False - - def testTransition(self, command): - # expect string as parameter - assert type(command) == str - # parse command - command = CompositeCommand.from_str(command) - self.log.debug("Testing if transition is available to '%s'", command) - return self.setCompositeEx(command.composite, command.A, - command.B, True, True) - - def getVideoSources(self): - ''' legacy command ''' - return [self.sourceA, self.sourceB] + pad.alpha = 0 + + def recalculateMixerStateSideBySidePreview(self): + self.log.info('Updating Mixer-State for ' + 'Side-by-side-Preview-Composition') + + width, height = self.getInputVideoSize() + self.log.debug('Video-Size parsed as %ux%u', width, height) + + try: + asize = [int(i) for i in Config.get('side-by-side-preview', + 'asize').split('x', 1)] + self.log.debug('A-Video-Size configured to %ux%u', + asize[0], asize[1]) + except NoOptionError: + asize = [ + int(width / 1.25), # 80% + int(height / 1.25) # 80% + ] + self.log.debug('A-Video-Size calculated to %ux%u', + asize[0], asize[1]) + + try: + acrop = [int(i) for i in Config.get('side-by-side-preview', + 'acrop').split('/', 3)] + self.log.debug('A-Video-Cropping configured to %u/%u/%u/%u', + acrop[0], acrop[1], acrop[2], acrop[3]) + except NoOptionError: + acrop = [0, 0, 0, 0] + self.log.debug('A-Video-Cropping calculated to %u/%u/%u/%u', + acrop[0], acrop[1], acrop[2], acrop[3]) + + try: + apos = [int(i) for i in Config.get('side-by-side-preview', + 'apos').split('/', 1)] + self.log.debug('A-Video-Position configured to %u/%u', + apos[0], apos[1]) + except NoOptionError: + apos = [ + int(width / 100), # 1% + int(width / 100) # 1% + ] + self.log.debug('A-Video-Position calculated to %u/%u', + apos[0], apos[1]) + + try: + bsize = [int(i) for i in Config.get('side-by-side-preview', + 'bsize').split('x', 1)] + self.log.debug('B-Video-Size configured to %ux%u', + bsize[0], bsize[1]) + except NoOptionError: + bsize = [ + int(width / 4), # 25% + int(height / 4) # 25% + ] + self.log.debug('B-Video-Size calculated to %ux%u', + bsize[0], bsize[1]) + + try: + bcrop = [int(i) for i in Config.get('side-by-side-preview', + 'bcrop').split('/', 3)] + self.log.debug('B-Video-Cropping configured to %u/%u/%u/%u', + bcrop[0], bcrop[1], bcrop[2], bcrop[3]) + except NoOptionError: + bcrop = [0, 0, 0, 0] + self.log.debug('B-Video-Cropping calculated to %u/%u/%u/%u', + bcrop[0], bcrop[1], bcrop[2], bcrop[3]) + + try: + bpos = [int(i) for i in Config.get('side-by-side-preview', + 'bpos').split('/', 1)] + self.log.debug('B-Video-Position configured to %u/%u', + bpos[0], bpos[1]) + except NoOptionError: + bpos = [ + width - int(width / 100) - bsize[0], + height - int(width / 100) - bsize[1] # 1% + ] + self.log.debug('B-Video-Position calculated to %u/%u', + bpos[0], bpos[1]) + + for idx, name in enumerate(self.names): + pad = self.padState[idx] + pad.reset() + + if idx == self.sourceA: + pad.xpos, pad.ypos = apos + pad.croptop, \ + pad.cropleft, \ + pad.cropbottom, \ + pad.cropright = acrop + pad.width, pad.height = asize + pad.zorder = 1 + + elif idx == self.sourceB: + pad.xpos, pad.ypos = bpos + pad.croptop, \ + pad.cropleft, \ + pad.cropbottom, \ + pad.cropright = bcrop + pad.width, pad.height = bsize + pad.zorder = 2 + + else: + pad.alpha = 0 + + def recalculateMixerStatePictureInPicture(self): + self.log.info('Updating Mixer-State for ' + 'Picture-in-Picture-Composition') + + width, height = self.getInputVideoSize() + self.log.debug('Video-Size parsed as %ux%u', width, height) + + try: + pipsize = [int(i) for i in Config.get('picture-in-picture', + 'pipsize').split('x', 1)] + self.log.debug('PIP-Size configured to %ux%u', + pipsize[0], pipsize[1]) + except NoOptionError: + pipsize = [ + int(width / 4), # 25% + int(height / 4) # 25% + ] + self.log.debug('PIP-Size calculated to %ux%u', + pipsize[0], pipsize[1]) + + try: + pipcrop = [int(i) for i in Config.get('picture-in-picture', + 'pipcrop').split('/', 3)] + self.log.debug('PIP-Video-Cropping configured to %u/%u/%u/%u', + pipcrop[0], pipcrop[1], pipcrop[2], pipcrop[3]) + except NoOptionError: + pipcrop = [0, 0, 0, 0] + self.log.debug('PIP-Video-Cropping calculated to %u/%u/%u/%u', + pipcrop[0], pipcrop[1], pipcrop[2], pipcrop[3]) + + try: + pippos = [int(i) for i in Config.get('picture-in-picture', + 'pippos').split('/', 1)] + self.log.debug('PIP-Position configured to %u/%u', + pippos[0], pippos[1]) + except NoOptionError: + pippos = [ + width - pipsize[0] - int(width / 100), # 1% + height - pipsize[1] - int(width / 100) # 1% + ] + self.log.debug('PIP-Position calculated to %u/%u', + pippos[0], pippos[1]) + + for idx, name in enumerate(self.names): + pad = self.padState[idx] + pad.reset() + + if idx == self.sourceA: + pass + elif idx == self.sourceB: + pad.xpos, pad.ypos = pippos + pad.width, pad.height = pipsize + pad.zorder = 2 + + else: + pad.alpha = 0 + + def applyMixerState(self): + for idx, state in enumerate(self.padState): + # mixerpad 0 = background + mixerpad = (self.mixingPipeline + .get_by_name('mix') + .get_static_pad('sink_%u' % (idx + 1))) + + cropper = self.mixingPipeline.get_by_name("video_%u_cropper" % idx) + + self.log.debug('Reconfiguring Mixerpad %u to ' + 'x/y=%u/%u, w/h=%u/%u alpha=%0.2f, zorder=%u', + idx, state.xpos, state.ypos, + state.width, state.height, + state.alpha, state.zorder) + mixerpad.set_property('xpos', state.xpos) + mixerpad.set_property('ypos', state.ypos) + mixerpad.set_property('width', state.width) + mixerpad.set_property('height', state.height) + mixerpad.set_property('alpha', state.alpha) + mixerpad.set_property('zorder', state.zorder) + + self.log.info("Reconfiguring Cropper %d to %d/%d/%d/%d", + idx, + state.croptop, + state.cropleft, + state.cropbottom, + state.cropright) + cropper.set_property("top", state.croptop) + cropper.set_property("left", state.cropleft) + cropper.set_property("bottom", state.cropbottom) + cropper.set_property("right", state.cropright) + + def selectCompositeModeDefaultSources(self): + sectionNames = { + CompositeModes.fullscreen: 'fullscreen', + CompositeModes.side_by_side_equal: 'side-by-side-equal', + CompositeModes.side_by_side_preview: 'side-by-side-preview', + CompositeModes.picture_in_picture: 'picture-in-picture' + } + + compositeModeName = self.compositeMode.name + sectionName = sectionNames[self.compositeMode] + + try: + defSource = Config.get(sectionName, 'default-a') + self.setVideoSourceA(self.names.index(defSource)) + self.log.info('Changing sourceA to default of Mode %s: %s', + compositeModeName, defSource) + except Exception as e: + pass + + try: + defSource = Config.get(sectionName, 'default-b') + self.setVideoSourceB(self.names.index(defSource)) + self.log.info('Changing sourceB to default of Mode %s: %s', + compositeModeName, defSource) + except Exception as e: + pass + + def on_handoff(self, object, buffer): + if self.padStateDirty: + self.padStateDirty = False + self.log.debug('[Streaming-Thread]: Pad-State is Dirty, ' + 'applying new Mixer-State') + self.applyMixerState() + + def on_eos(self, bus, message): + self.log.debug('Received End-of-Stream-Signal on Mixing-Pipeline') + + def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Mixing-Pipeline') + (error, debug) = message.parse_error() + self.log.debug('Error-Details: #%u: %s', error.code, debug) def setVideoSourceA(self, source): - ''' legacy command ''' - self.setCompositeEx(None, source, None, useTransitions=False) + # swap if required + if self.sourceB == source: + self.sourceB = self.sourceA + + self.sourceA = source + self.recalculateMixerState() def getVideoSourceA(self): - ''' legacy command ''' return self.sourceA def setVideoSourceB(self, source): - ''' legacy command ''' - self.setCompositeEx(None, None, source, useTransitions=False) + # swap if required + if self.sourceA == source: + self.sourceA = self.sourceB + + self.sourceB = source + self.recalculateMixerState() def getVideoSourceB(self): - ''' legacy command ''' return self.sourceB - def setCompositeMode(self, mode): - ''' legacy command ''' - self.setCompositeEx(mode, None, None, useTransitions=False) + def setCompositeMode(self, mode, apply_default_source=True): + self.compositeMode = mode + + if apply_default_source: + self.selectCompositeModeDefaultSources() + + self.recalculateMixerState() def getCompositeMode(self): - ''' legacy command ''' return self.compositeMode - - def getComposite(self): - ''' legacy command ''' - return str(CompositeCommand(self.compositeMode, self.sourceA, self.sourceB)) - - def setOverlay(self, location): - ''' set up overlay file by location ''' - self.overlay.set(location) - - def showOverlay(self, visible): - ''' set overlay visibility ''' - self.overlay.show(visible, self.getPlayTime()) - - def getOverlay(self): - ''' get current overlay file location ''' - return self.overlay.get() - - def getOverlayVisible(self): - ''' get overlay visibility ''' - return self.overlay.visible() diff -Nru voctomix-1.3+git20200101/voctocore/README.md voctomix-1.3+git20200102/voctocore/README.md --- voctomix-1.3+git20200101/voctocore/README.md 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/README.md 2020-01-03 00:02:24.000000000 +0000 @@ -1,667 +1,220 @@ -# 1. VOC2CORE +# Voctocore - The videomixer core-process -## 1.1. Contents +## Design goals +Our Design is heavily influenced by gst-switch. We wanted a small videomixer core-process, whose sourcecode can be read and understand in about a weekend. +All Sources (Cameras, Slide-Grabbers) and Sinks (Streaming, Recording) should be separate Processes. As far as possible we wanted to reuse our existing and well-tested ffmpeg Commandlines for streaming and recording. It should be possible to connect additional Sinks at any time while the number of Sources is predefined in our Setup. All sources and sinks should be able to die and get restarted without taking the core process down. +While Sources and Sinks all run on the same Machine, Control- or Monitoring-Clients, for example a GUI, should be able to run on a different machine and connect to the core-process via Gigabit Ethernet. The core-process should be controllable by a very simple protocol which can easily be scripted or spoken with usual networking tools. - +## Design decisions +To meet our goal of "read and understand in about a weekend" python was chosen as language for the high-level parts, with [GStreamer](http://gstreamer.freedesktop.org/) for the low-level media handling. GStreamer can be controlled via the [PyGI](https://wiki.gnome.org/action/show/Projects/PyGObject) bindings from Python. +As an Idea borrowed from gst-switch, all Video- and Audio-Streams to and from the core are handled via TCP-Connections. Because they transport raw Video-Frames the only reasonable transport is via the loopback interface or a dedicated GBit-NIC (1920×1080×2 (UYVY)×8 (Bits)×25 (fps) = ~830 MBit/s). Nevertheless TCP is a quite efficient and good supported transport mechanism. For compatibility with ffmpeg and because of its good properties when streamed over TCP, [Matroska](http://www.matroska.org/) was chosen as a Container. -- [1.1. Contents](#11-contents) -- [1.2. Purpose](#12-purpose) -- [1.3. Features](#13-features) -- [1.4. Installation](#14-installation) - - [1.4.1. Debian / Ubuntu](#141-debian--ubuntu) - - [1.4.2. Requirements](#142-requirements) - - [1.4.3. For vaapi en/decoding](#143-for-vaapi-endecoding) - - [1.4.4. Optional for the Example-Scripts](#144-optional-for-the-example-scripts) -- [1.5. Debugging](#15-debugging) -- [1.6. Mixing Pipeline](#16-mixing-pipeline) - - [1.6.1. Input Elements](#161-input-elements) - - [1.6.1.1. Sources](#1611-sources) - - [1.6.1.1.1. Test Sources](#16111-test-sources) - - [1.6.1.1.2. TCP Sources](#16112-tcp-sources) - - [1.6.1.1.3. File Sources](#16113-file-sources) - - [1.6.1.1.4. Decklink Sources](#16114-decklink-sources) - - [1.6.1.1.5. Image Sources](#16115-image-sources) - - [1.6.1.1.6. Video4Linux2 Sources](#16116-video4linux2-sources) - - [1.6.1.1.7. Common Source Attributes](#16117-common-source-attributes) - - [1.6.1.2. Background Video Source](#1612-background-video-source) - - [1.6.1.2.1. Multiple Background Video Sources (depending on Composite)](#16121-multiple-background-video-sources-depending-on-composite) - - [1.6.1.3. Blinding Sources (Video and Audio)](#1613-blinding-sources-video-and-audio) - - [1.6.1.3.1. A/V Blinding Source](#16131-av-blinding-source) - - [1.6.1.3.2. Separated Audio and Video Blinding Source](#16132-separated-audio-and-video-blinding-source) - - [1.6.1.4. Overlay Sources](#1614-overlay-sources) - - [1.6.1.4.1. Single Overlay Image File](#16141-single-overlay-image-file) - - [1.6.1.4.2. Multiple Overlay Image Files](#16142-multiple-overlay-image-files) - - [1.6.1.4.3. Select Overlays from a Schedule](#16143-select-overlays-from-a-schedule) - - [1.6.1.4.3.1. Filtering Events](#161431-filtering-events) - - [1.6.1.4.4. Additional Overlay Options](#16144-additional-overlay-options) - - [1.6.1.4.4.1. Auto-Off](#161441-auto-off) - - [1.6.2. Output Elements](#162-output-elements) - - [1.6.2.1. Mix Live](#1621-mix-live) - - [1.6.2.2. Mix Recording](#1622-mix-recording) - - [1.6.2.3. Mix Preview](#1623-mix-preview) - - [1.6.2.4. Sources Live](#1624-sources-live) - - [1.6.2.5. Sources Recording](#1625-sources-recording) - - [1.6.2.6. Sources Preview](#1626-sources-preview) - - [1.6.2.7. Mirror Ports](#1627-mirror-ports) - - [1.6.3. A/V Processing Elements](#163-av-processing-elements) - - [1.6.3.1. DeMux](#1631-demux) - - [1.6.3.2. Mux](#1632-mux) - - [1.6.4. Video Processing Elements](#164-video-processing-elements) - - [1.6.4.1. Scale](#1641-scale) - - [1.6.4.2. Mix Compositor](#1642-mix-compositor) - - [1.6.4.3. Mix Blinding Compositor](#1643-mix-blinding-compositor) - - [1.6.4.4. Sources Blinding Compositor](#1644-sources-blinding-compositor) - - [1.6.5. Audio Processing Elements](#165-audio-processing-elements) - - [1.6.5.1. Audio Mixer](#1651-audio-mixer) - - [1.6.5.2. Audio Blinding Mixer](#1652-audio-blinding-mixer) - - [1.6.6. Live Sources](#166-live-sources) -- [1.7. Decoder and Encoder](#17-decoder-and-encoder) - - [1.7.1. CPU](#171-cpu) - - [1.7.2. VAAPI](#172-vaapi) - - - -## 1.2. Purpose - -**VOC2CORE** is a server written in python which listens at port `9999` for incoming TCP connections to provide a command line interface to manipulate a [GStreamer](http://gstreamer.freedesktop.org/) pipeline it runs. -The gstreamer pipeline is meant to mix several incoming video and audio sources to different output sources. - -Particularly it can be used to send mixtures of the incoming video and audio sources to a live audience and/or to a recording server. - -**VOC2CORE** can be easily adapted to different scenarios by changing it's configuration. - -One can use a simple terminal connection to control the mixing process or **VOC2GUI** which provides a visual interface that shows previews of all sources and the mixed output as well as a toolbar for all mixing commands. - -## 1.3. Features - -**VOC2CORE** currently provides the following features: - -* [Matroska](http://www.matroska.org/) enveloped A/V source input via TCP -* Image sources via URI -* [Decklink](https://www.blackmagicdesign.com/products/decklink) grabbed A/V sources -* Video4Linux2 video sources -* GStreamer generated test sources -* [Matroska](http://www.matroska.org/) enveloped A/V output via TCP -* Scaling of input sources to the desired output format -* Conversion of input formats to the desired output format -* Composition of video sources to a mixed video output (e.g. Picture in Picture) -* Blinding of mixed video and audio output (formerly known as "stream blanking", e.g. to interrupt live streaming between talks) -* Low resolution preview outputs of sources and mix for lower bandwidth monitoring -* High resolution outputs of sources and mix for high quality recording -* Remote controlling via command line interface -* Video transitions for fading any cuts -* Image overlays (e.g. for lower thirds) -* Reading a so-called [`schedule.xml`](https://github.com/voc/voctosched) which can provide meta data about the talks and that is used to address images individually for each talk that can be selected as overlay (e.g. speaker descriptions in lower thirds) -* Customization of video composites and transitions - -## 1.4. Installation - -Currently voc2mix is only works on linux based operating systems. Currently its tested on ubuntu 18.04 and 19.10 as well -as debian buster. It will probably work on most linux distributions which can satisfy the dependencies below. -Voc2mix can run on Gstreamer version < 1.8 but at least 1.14 is recommended. - -### 1.4.1. Debian / Ubuntu - -On Ubuntu 18.04 to 19.10 and Debian buster the following packages are needed. The python dependencies can also be handled in a venv. -Both Debian and Ubuntu provide voctomix packages which my or may not be outdated. Currently its recommended to check out voc2mix from the git repository. - -````bash -git clone https://github.com/voc/voctomix.git -git checkout voctomix2 -```` +The ubiquitous Input/Output-Format into the core-process is therefore Raw UYVY Frames and Raw S16LE Audio in a Matroska container for Timestamping via TCP over localhost. Network handling is done in python, because it allows for greater flexibility. After the TCP connection is ready, its file descriptor is passed to GStreamer which handles the low-level read/write operations. To be able to attach/detach sinks, the `multifdsink`-Element can be used. For the Sources it's more complicated: -### 1.4.2. Requirements +When a source is not connected, its video and audio stream must be substituted with black frames and silence, to that the remaining parts of the pipeline can keep on running. To achive this, a separate GStreamer-Pipeline is launched for an incoming Source-Connection and destroyed in case of a disconnect or an error. To pass Video -and Audio-Buffers between the Source-Pipelines and the other parts of the Mixer, we make use of the `inter(audio/video)(sink/source)`-Elements. `intervideosrc` and `interaudiosrc` implement the creation of black frames and silence, in case no source is connected or the source broke down somehow. -````bash -sudo apt install gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly gstreamer1.0-tools libgstreamer1.0-0 python3 python3-gi gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 python3-sdnotify python3-scipy -```` +If enabled in Config, the core process offers two formats for most outputs: Raw-Frames in mkv as described above, which should be used to feed recording or streaming processes running on the same machine. For the GUI which usually runs on a different computer, they are not suited because of the bandwidth requirements (1920×1080 UYVY @25fps = 791 MBit/s). For this reason the Servers offers Preview-Ports for each Input and the Main-Mix, which serves the same content, but the video frames there are jpeg compressed, combined with uncompressed S16LE audio and encapsulated in mkv. -### 1.4.3. For vaapi en/decoding +Also, if enabled in Config, another Building-Block is chained after the Main-Mix: the StreamBlanker. It is used in Cases when there should be no Stream, for example in Breaks between Talks. It is sourced from one ASource which usually accepts a Stream of Music-Loop and one or more VSources which usually accepts a "There is currently no Talk"-Loop. Because multiple VSources can be configured, one can additionally source a "We are not allowed to Stream this Talk" or any other Loop. All Video-Loops are combined with the Audio-Loop and can be selected from the GUI. -````bash -sudo apt install gstreamer1.0-vaapi +## Block-Level Diagram ```` - -### 1.4.4. Optional for the Example-Scripts - -````bash -sudo apt install python3-pyinotify gstreamer1.0-libav rlwrap fbset ffmpeg netcat +17000… VSource** (Stream-Blanker) ---\ +18000 ASource** (Stream-Blanker) ----\ + \ +16000 VSource (Background) \ + \ \ + --> VideoMix \ + / \ -> StreamBlanker** -> StreamOutputPort** 15000 + / \ / + / ------> OutputPort 11000 + / / \-> Encoder* -> PreviewPort* 12000 + / / + /----- -> AudioMix + / +10000… AVSource --> MirrorPort 13000… + \-> Encoder* -> PreviewPort* 14000… + \ + \ + \--> Slides -> SlidesStreamBlanker*** -> SlidesStreamOutputPort** 15001 + +9999 Control-Server +9998 GstNetTimeProvider Network-Clock + +*) only when [previews] enabled=true is configured +**) only when [stream-blanker] enabled=true is configured +***) only when [mix] slides_source_name=… is configured ```` -## 1.5. Debugging +## Network Ports Listing +Ports that will accept Raw UYVY Frames and Raw S16LE Audio in a Matroska container: + - 10000, 10001, … – Main Video-Sources, depending on the number of configured Sources -Here are some debugging tips: +Ports that will accept Raw UYVY Frames without Audio in a Matroska container: + - 16000 Mixer – Background Loop + - 17000, 17001, … – Stream-Blanker Video-Input, depending on the number of configured Stream-Blanker-Sources -* Use option `-v`, `-vv` or `-vvv` to set more and more verbose logging from **VOC2CORE**. -* Use option `-g`, `-gg` to set more and more verbose logging from GStreamer. -* Use option `-p` to generate file including string of pipeline that **VOC2CORE** is about to create. -* Use option `-d` to generate DOT graphs of the GStreamer pipeline that **VOC2CORE** has created. -* Use option `-D` to generate DOT graphs like with `-d` but set detail level of DOT graph. -* DOT graph files can be viewed with `xdot` for example. +Ports that will accept Raw S16LE Audio wihout Video in a Matroska container: + - 18000 – Stream-Blanker Audio-Input -## 1.6. Mixing Pipeline +Ports that will provide Raw UYVY Frames and Raw S16LE Audio in a Matroska container: + - 13000, 13001, … – Main Video-Source Mirrors, depending on the number of configured Sources + - 11000 – Main Mixer Output + - 15000 – Stream Output – only when [stream-blanker] enabled=true is configured -The following graph shows a simplified mixing pipeline. -The real GStreamer pipeline is much more complicated. -A so-called [DOT graph](https://www.graphviz.org/) of it can be generated by starting **VOC2CORE** with option `-d`. Those DOT graph files can be viewed with [xdot](https://github.com/jrfonseca/xdot.py) for example. +Ports that will provide JPEG Frames and Raw S16LE Audio in a Matroska container – only when [previews] enabled=true is configured + - 14000, 14001, … – Main Video-Source Mirrors, depending on the number of configured Sources + - 12000 – Main Mixer Output -![**VOC2CORE** Mixing Pipeline](images/pipelines.svg) +Port 9999 will Accept Control Protocol Connections. -### 1.6.1. Input Elements +## Control Protocol +To Control operation of the Video-Mixer, a simple line-based TCP-Protocol is used. The Video-Mixer accepts connection on TCP-Port 9999. The Control-Protocol is currently unstable and may change in any way at any given time. Regarding available Commands and their Reponses, the Code is the Documentation. There are 3 kinds of Messages: -#### 1.6.1.1. Sources +### 1. Commands from Client to Server +The Client may send Commands listed in the [Commands-File](./lib/commands.py). Each Command takes a number of Arguments which are separated by Space. There is currently no way to escape Spaces or Linebreaks in Arguments. A Command ends with a Unix-Linebreak. -Live audio/video input can be delivered in different *kinds* via **TCP** in **Matroska format** or from a **Decklink capture card** source. **Video4Linux2** devices can also be used as video only sources. It is also possible to use **image** and **test** sources, but this mostly make sense for testing purposes. +There are two Kinds of Commands: `set_*` and `get_*`. `set`-Commands change the Mixer state while `get`-Commands dont. Both kinds of Commands are answered with the same Response-Message. -All input sources must be named uniquely and listed in `mix/sources` within the configuration file: +For example a `set_video_a cam1` Command could be respnded to with a `video_status cam1 cam2` Response-Message. A `get_video` Command will be answered with exactly the same Message. -```ini -[mix] -sources = cam1,cam2 -``` +### 2. Errors in response to Commands +When a Command was invalid or had invalid Parameters, the Server responds with `error` followed by a Human Readable error message. A Machine-Readable error code is currently not available. The Error-Response always ends with a Unix Linebreak (The Message can not contain Linebreaks itself). -Without any further configuration this will produce two test sources named `cam1` and `cam2`. +### 3. Server Signals +When another Client issues a Command and the Server executed it successfully, the Server will signal this to all connected Clients. The Signal-Message format is identical to the Response-Format of the issued Command. -##### 1.6.1.1.1. Test Sources +For example if Client `A` issued Command `set_video_a cam1`, Client `A`and Client `B` will both receive the same `video_status cam1 cam2` Response-Message. -Without any further configuration a source becomes a **test source** by default. -Every test source will add a [videotestsrc](https://gstreamer.freedesktop.org/documentation/videotestsrc/index.html?gi-language=python) and an [audiotestsrc](https://gstreamer.freedesktop.org/documentation/audiotestsrc/index.html?gi-language=python) element to the internal GStreamer pipeline and so it produces a test video and sound. -As in the order they appear in `mix/sources` the test patterns of all test sources will iterate through the following values: +### Example Communication: +```` +< set_video_a cam1 +> video_status cam1 cam2 -`smpte`, `ball`, `red`, `green`, `blue`, `black`, `white`, `checkers-1`, `checkers-2`, `checkers-4`, `checkers-8`, `circular`, `blink`, `smpte75`, `zone-plate`, `gamut`, `chroma-zone-plate`, `solid-color`, `smpte100`, `bar`, `snow`, `pinwheel`, `spokes`, `gradient`, `colors` +< set_composite_mode side_by_side_equal +> composite_mode side_by_side_equal -You can also set a specific audio pattern by setting `mix/wave` to one the following types: +< set_videos_and_composite grabber * fullscreen +> video_status grabber cam1 +> composite_mode fullscreen -`sine`, `square`, `saw`, `triangle`, `silence`, `white-noise`, `pink-noise`, `sine-table`, `ticks`, `gaussian-noise`, `red-noise`, `blue-noise`, `violet-noise`, +< get_video +> video_status cam2 cam1 -The default is `sine`, with a frequency of 1kHz at -18dbFS. +< get_composite_mode +> composite_mode fullscreen -To set the pattern of a test source explicitly you need to add an own section `source.x` (where `x` is the source's identifier) to the configuration +< set_video_a blafoo +> error unknown name foo -```ini -[mix] -sources = cam1,cam2 +< get_stream_status +> stream_status live -[source.cam1] -pattern = ball -wave = white-noise -``` +< set_stream_blank pause +> stream_status blank pause -Now source `cam1` will show a moving white ball on black background instead of a *SMPTE* pattern signal and play white noise instead of a sine. +< set_stream_live +> stream_status live -To change the *kind* of a source you need to set the `kind` attribute in the source's configuration section as described in the following paragraphs. +… meanwhile in another control-server connection -##### 1.6.1.1.2. TCP Sources +> video_status cam1 cam2 +> video_status cam2 cam1 +> composite_mode side_by_side_equal +> composite_mode fullscreen +> stream_status blank pause +> stream_status live -You can use `tcp` as a source's `kind` if you would like to provide Matroska A/V streams via TCP. -**TCP sources** will be assigned to port `16000` and the following in the order in which they appear in `mix/sources`. +```` -```ini -[mix] -sources = cam1,cam2 +### Messages +Messages are Client-to-Client information that don't change the Mixers state, while being distributed throuh its Control-Socket. -[source.cam1] -kind = tcp +```` +< message cut bar moo +> message cut bar moo -[source.cam2] -kind = tcp -``` +… meanwhile in another control-server connection -This configuration let VOC2CORE listen at port `16000` for an incoming TCP connection transporting a Matroska A/V stream for source `cam1` and at port `16001` for source `cam2`. +> message cut bar moo +```` -##### 1.6.1.1.3. File Sources +They can be used to Implement Features like a "Cut-Button" in the GUI. When Clicked the GUI would emit a message to the Server which would distribute it to all Control-Clients. A recording Script may receive the notification and rotate its output-File. -You can use `file` as a source's `kind` if you would like to provide a file that will be played e.g. to provide a blinder animation. Setting the loop property to `false` is not useful at this point. +## Configuration +On Startup the Video-Mixer reads the following Configuration-Files: + - `/default-config.ini` + - `/config.ini` + - `/etc/voctomix/voctocore.ini` + - `/etc/voctocore.ini` + - `/.voctocore.ini` + - `` -Currently, file sources are expected to be MPEG TS containers with MPEG-2 Video and MP2 or MP3 audio. Support of further container, audio and video types may be supported in future releases +From top to bottom the individual Settings override previous Settings. `default-config.ini` should not be edited, because a missing Setting will result in an Exception. -```ini -[source.blinder] -kind=file -location=/path/to/pause.ts -loop=true -``` +All Settings configured in the Server are available via the `get_config` Call on the Control-Port and will be used by the Clients, so there will be no need to duplicate Configuration options between Server and Clients. -This configuration will loop pause.ts as the default blinder, using its audio and video +## Multi-Stream Audio Mixing +Voctomix has support for passing and mixing as many audio streams as desired. At the c3voc we use this feature for recording lectures with simultaneous translation. The number of streams is configured system-wide with the `[mix] audiostreams` setting which defaults to 1. All streams are always stereo. Setting it to 3 configures 3 stereo-streams. -##### 1.6.1.1.4. Decklink Sources +Each tcp-feed for a camera (not stream-blanker and background-feeds) then needs to follow this channel layout (in this example: have 3 stereo-stream) or it will stall after the first couple seconds. -You can use `decklink` as a source's `kind` if you would like to grab audio and video from a [Decklink](https://www.blackmagicdesign.com/products/decklink) grabber card. +Similar all output-streams (mirrors, main-out, stream-out) will now present 3 stereo-streams. The streamblanker will correctly copy the blank-music to all streams when the stream-blanker is engaged. -```ini +For the internal decklink-sources, you have to configure the mapping in the source-section of the config: +``` [mix] -sources = cam1,cam2 +… +audiostreams = 3 [source.cam1] kind = decklink -devicenumber = 1 - -[source.cam2] -kind = decklink -devicenumber = 3 -``` - -You now have two **Decklink A/V grabber** sources at device number `1` for `cam1` and `3` for `cam2`. - -Optional attributes of Decklink sources are: - -| Attribute Name | Example Values | Default | Description (follow link) -| ------------------ | -------------------------------------------------- | --------- | ----------------------------------------- -| `devicenumber` | `0`, `1`, `2`, ... | `0` | [Decklink `device-number`](https://gstreamer.freedesktop.org/documentation/decklink/decklinkvideosrc.html#decklinkvideosrc:device-number) -| `video_connection` | `auto`, `SDI`, `HDMI`, ... | `auto` | [Decklink `connection`](https://gstreamer.freedesktop.org/documentation/decklink/decklinkvideosrc.html#GstDecklinkConnection) -| `video_mode` | `auto`, `1080p25`, `1080i50`, ... | `auto` | [Decklink `modes`](https://gstreamer.freedesktop.org/documentation/decklink/decklinkvideosrc.html#decklinkvideosrc_GstDecklinkModes) -| `video_format` | `auto`, `8bit-YUV`, `10bit-YUV`, `8bit-ARGB`, ... | `auto` | [Decklink `video-format`](https://gstreamer.freedesktop.org/documentation/decklink/decklinkvideosrc.html#decklinkvideosrc_GstDecklinkVideoFormat) -| `audio_connection` | `auto`, `embedded`, `aes`, `analog`, `analog-xlr`, `analog-rca` | `auto` | [Decklink `audio-connection`](https://gstreamer.freedesktop.org/documentation/decklink/decklinkaudiosrc.html#GstDecklinkAudioConnection) - -##### 1.6.1.1.5. Image Sources +devicenumber = 0 +video_connection = SDI +video_mode = 1080p25 +audio_connection = embedded -You can use `img` as a source's `kind` if you would like to generate a still video from an image file. +# Use audio from this camera +volume=1.0 -```ini -[mix] -sources = cam1,cam2 - -[source.cam1] -kind = img -imguri = http://domain.com/image.jpg +# Map SDI-Channel 0 to the left ear and Channel 1 to the right ear of the Output-Stream 0 +audiostream[0] = 0+1 [source.cam2] -kind = img -file = /opt/voctomix/image.png -``` - -As you see you can use either `imguri` or `file` to select an image to use. - -| Attribute Name | Example Values | Default | Description -| ------------------ | -------------------------------------------------- | --------- | ----------------------------------------- -| `imguri` | `http://domain.com/image.jpg` | n/a | use image from URI -| `file` | `/opt/voctomix/image.png` | n/a | use image from local file - -##### 1.6.1.1.6. Video4Linux2 Sources - -You can use `v4l2` as a source's `kind` if you would like to use video4linux2 devices as video input. -To get the supported video modes, resolution and framerate you can use ffprobe and ffplay. - -```bash -ffprobe /dev/video0 -``` +kind = decklink +devicenumber = 1 +video_connection = SDI +video_mode = 1080p25 +audio_connection = embedded -```ini -[mix] -sources = cam1,cam2 +# Use audio from this camera +volume=1.0 -[source.cam1] -kind=v4l2 -device=/dev/video2 -width=1280 -height=720 -framerate=10/1 -format=YUY2 +# Map SDI-Channel 0 to both ears ear of the Output-Stream 1 +audiostream[1] = 0 +# Map SDI-Channel 1 to both ears ear of the Output-Stream 2 +audiostream[2] = 1 ``` -| Attribute Name | Example Values | Default | Description -| ------------------ | -------------------------------------------------- | ----------- | ----------------------------------------- -| `device` | `/dev/video0` | /dev/video0 | video4linux2 device to use -| `width` | `1280` | 1920 | video width expected from the source -| `height` | `720` | 1080 | video height expected from the source -| `framerate` | `10/1` | 25/1 | video frame rate expected from the source -| `format` | `YUY2` | YUY2 | video format expected from the source - -##### 1.6.1.1.7. Common Source Attributes - -These attributes can be set for all *kinds* of sources: - -| Attribute Name | Example Values | Default | Description -| ------------------ | -------------------------------------------------- | ------------- | ----------------------------------------- -| `scan` | `progressive`, `interlaced`, `psf` | `progressive` | select video mode (`psf` = Progressive segmented frame) -| `volume` | `0.0`, ..., `1.0` | `0.0` | audio volume (if reasonable) - -#### 1.6.1.2. Background Video Source - -The `background` source is *obligatory* and does not have to be listed in `mix/sources`. -The background source will be placed on bottom (z-order) of the video mix. -By default the background source is a `black` video test source. -Yout need to configure the background source (as any other) if you want to change that: - -```ini -[source.background] -kind=img -file=bg.png +With Audio-Embedders which can embed more then 2 Channels onto an SDI-Stream you can also fill all Streams from one SDI-Source. This requires at least GStreamer 1.12.3: ``` - -The background source is **video only** and so any audio sources will be ignored. - -##### 1.6.1.2.1. Multiple Background Video Sources (depending on Composite) - -You may also have multiple backgrounds and attach them to your composites. -Often - for example - you have a logo in the background which needs to be shown on different places depending on where your composites leave unused space. - -By configuring a list of background sources in `mix`/`backgrounds` you can configure every single one of them. -The default background source called `background` wont be used then. - -```ini [mix] -backgrounds=background1,background2 - -[source.background1] -kind=img -file=bg.png -composites=fs - -[source.background2] -kind=test -pattern=black -composites=sbs,pip -``` - -To control when the backgrounds will be used just add a list of `composites` to your source configuration. -The core then will search for backgrounds that match the current composite and cut or fade them when composites are switched. - -#### 1.6.1.3. Blinding Sources (Video and Audio) - -The blinder (fka stream-blanker) blinds all live outputs. -You can activate the blinder in the configuration like that: - -```ini -[blinder] -enable=true -``` - -By default the blinder generates a Gstreamer test source which shows a SMPTE pattern. -But you have several options to define your own blinder sources: - -##### 1.6.1.3.1. A/V Blinding Source - -If you like to set up a custom blinding source you have to configure a source that is named `blinder`: - -```ini -[blinder] -enable=true - -[source.blinder] -kind=test -pattern=black -volume=0.0 -``` - -This would define a blinder source that is a black screen with silent audio. -But you can use any other source kind too. - -##### 1.6.1.3.2. Separated Audio and Video Blinding Source - -Another way to define binding sources is to configure one audio source and one or more video sources. -The blinder then will blind with the one given audio source but you can select between different video sources. -This is useful if you want to have different video messages which you want to differ (for different day times for example, like having a break at lunch or end of the event or a trouble message. -If you want to do so, you have to define the audio source within the blinding source and add as many video blinding sources within the `blinder` section: - -```ini -[blinder] -enable=true -videos=break,closed - -[source.blinder] -kind=tcp - -[source.break] -kind=tcp - -[source.closed] -kind=tcp -``` - -This will listen at three different ports for the audio source, the break video source and the closed video source. - -#### 1.6.1.4. Overlay Sources - -Overlays are placed on top (z-order) of the video mix. -Currently they can be provided as bitmap images only. - -These bitmap images will be loaded from the current working directory. -If you want to specify an image directory you can use the attribute `overlay`/`path`: - -```ini -[overlay] -path = ./data/images/overlays -``` - -Now all images will be loaded from the folder `./data/images/overlays`. - -You can configure which overlay images will be available for an insertion in three different ways selectively or in parallel. - -##### 1.6.1.4.1. Single Overlay Image File - -The simplest method is to set a single overlay file that will be displayed as overlay initially after the server starts: - -```ini -[overlay] -file = watermark.png|Watermark Sign -``` - -The given file name can be followed by a `|` and a verbal description of the file's contents which substitutes the filename within selections in the user interface. - -##### 1.6.1.4.2. Multiple Overlay Image Files - -You can also list multiple files which then can be selected between by using the property `files`: - -```ini -[overlay] -files = first.png|1st overlay, second.png|2nd overlay, third.png|3rd overlay -``` - -Same principle but a comma separated list of image names and descriptions. -The `files` attribute will not activate an overlay at server start. - -##### 1.6.1.4.3. Select Overlays from a Schedule - -A more complex method is to configure a schedule file which is an XML file including information about one or multiple event schedules. -From these information **VOC2MIX** can generate file names in the form of `event_{eid}_person_{pid}.png` or `event_{eid}_persons.png` where `{eid}` and `{pid}` are placeholders for the event/id and person ID of the speakers of the event. -The first variant is used to address every single speaker and the second variant all participating persons at once. - -Below you can see an example consisting of the necessary XML elements and by that describing three events and up to three speakers. - -```xml - - - - - - 2019-01-01T10:00:00+02:00 - 01:00 - HALL 1 - Interesting talk in HALL 1 at 10:00 - - Alice - Bob - Claire - - - - 2019-01-01T10:00:00+02:00 - 01:00 - HALL 2 - Interesting talk in HALL 2 at 10:00 - - Dick - - - - 2019-01-01T11:15:00+02:00 - 01:00 - HALL 2 - Interesting talk in HALL 2 at 11:15 - - Alice - Dick - - - - - -``` - -From this file **VOC2MIX** will generate the following file names (and descriptions) for which it will search: - -```txt -event_1_person_1.png|Alice -event_1_person_2.png|Bob -event_1_person_3.png|Claire -event_1_persons.png|Alice, Bob, Claire - -event_2_person_4.png|Dick - -event_3_person_1.png|Alice -event_3_person_4.png|Dick -event_3_persons.png|Alice, Dick -``` - -**VOC2CORE** will present a list of all available (files present in file system) overlays if asked for. - -###### 1.6.1.4.3.1. Filtering Events - -If you have multiple events in multiple rooms it might be of need to filter the current event which you are mixing. -The first filter criteria will always be the current time. -**VOC2MIX** automatically filters out events that are past or in future. - -Additionally you might set the room ID to filter out all events which are not happening in the room you are mixing. - -```ini -[overlay] -schedule=schedule.xml -room=HALL 1 -``` - -Now **VOC2CORE** will list you the available overlay images only for room `HALL 1`. - -##### 1.6.1.4.4. Additional Overlay Options - -###### 1.6.1.4.4.1. Auto-Off - -**VOC2GUI** presents a button called `auto-off` which can be switched on and off. -Selection a different insertion from the list or the change of the current composite will force to end a current insertion. -This is used to prevent uncomfortable visual effects. - -```ini -[overlay] -user-auto-off = true -auto-off = false -blend-time=300 -``` - -If `user-auto-off` is set the button can be switched by the user and it is present within the user interface of **VOC2GUI**. -`auto-off` sets the initial state of the auto-off feature. -And `blend-time` sets the duration of the in and out blending of overlays in milliseconds. - -### 1.6.2. Output Elements - -#### 1.6.2.1. Mix Live - -This is the mix that is intended as output for live streaming. It get blanked by the stream blanker when -the live button in the GUI is disabled. - -#### 1.6.2.2. Mix Recording - -This is the mix that is intended as output for recording. It is identical to the video displayed int the GUI as **MIX**. +… +audiostreams = 3 -#### 1.6.2.3. Mix Preview - -This mix is intended for the **MIX** preview in the GUI. - -#### 1.6.2.4. Sources Live - -#### 1.6.2.5. Sources Recording - -#### 1.6.2.6. Sources Preview - -Source Preview elements are used to encode the different video streams which will be shown in the GUI. -If previews are not enabled the GUI will use the raw video mirror ports. This only can work if gui and core are running on the same machine. - -```ini -[previews] -enabled = true -live = true -vaapi=h264 -videocaps=video/x-raw,width=1024,height=576,framerate=25/1 -``` - -| Attribute Name | Example Values | Default | Description -| ------------------ | ----------------------------------- | ----------- | ----------------------------------------- -| `enable` | `true` | false | video4linux2 device to use -| `live` | `true` | false | video width expected from the source -| `vaapi` | `h264` | | h264, mpeg2 and jpeg are supported. If jpeg is used CPU decoding needs to be used ob the gui. -| `scale-method` | 2 | 0 | 0: Default scaling mode 1: Fast scaling mode, at the expense of quality 2: High quality scaling mode, at the expense of speed. -| `vaapi-denoise` | true | false | use VAAPI to denoise the video before encoding it - -#### 1.6.2.7. Mirror Ports - -Mirror ports provide a copy of the input stream of each source via an TCP port. - -```ini -[mirrors] -enabled=true -``` - -| Attribute Name | Example Values | Default | Description -| ------------------ | ----------------------------------- | ----------- | ----------------------------------------- -| `enable` | `true` | false | - -### 1.6.3. A/V Processing Elements - -#### 1.6.3.1. DeMux - -#### 1.6.3.2. Mux - -### 1.6.4. Video Processing Elements - -#### 1.6.4.1. Scale - -#### 1.6.4.2. Mix Compositor - -#### 1.6.4.3. Mix Blinding Compositor - -#### 1.6.4.4. Sources Blinding Compositor - -### 1.6.5. Audio Processing Elements - -#### 1.6.5.1. Audio Mixer - -#### 1.6.5.2. Audio Blinding Mixer - -### 1.6.6. Live Sources - -If you want to expose sources (e.g. a slide grabber) as an additional output for recording and streaming purposes, use the `mix/livesources` directive, which takes a comma-separated list of sources to be exposed, like so: - -```ini -[mix] -sources=cam1,cam2,grabber -livesources=grabber +[source.cam1] +kind = decklink +devicenumber = 0 +video_connection = SDI +video_mode = 1080p25 +audio_connection = embedded + +# Use audio from this camera +volume=1.0 + +# Map SDI-Channel 0 to the left ear and Channel 1 to the right ear of the Output-Stream 0 +audiostream[0] = 0+1 +audiostream[1] = 2+3 +audiostream[2] = 4+5 ``` - -This will expose the grabber on port 15001. If you specify further sources, they will appear on ports 15002, etc. - -## 1.7. Decoder and Encoder - -Voc2mix needs to encoder and decode video on different place in the pipeline as well as in the GUI. -Encoding and decoding can consume much time of the CPU. Therefore this tasks can be offloaded to fixed function en-/decoder blocks. -Probably the most common architecture for this, at least on x86, is Intels VAAPI interface which is is not limited to intel GPUs. -Most Intel CPUs with build in GPU provide these functions with different feature sets. -As there is also a penalty one using these as the data needs to be up and downloaded to the GPU the impact of using a offloading in favor of -CPU en-/decoding differs depending an a number of variables. -Also the quality that can be expected from offloading differs on the hardware used. - -### 1.7.1. CPU - -Voc2mix can use all software en-/decoder gstreamer provides. The current code offer h264, mpeg2 and jpeg. - -### 1.7.2. VAAPI - -* -* -* - -To use VAAPI with voc2mix on intel GPUs at least a sandy bridge generation CPU is required. -Voc2mix can use the the vaapi encoder to encode the preview stream for the GUI. -The GUI it self can use VAAPI to decode the preview streams and also use VAAPI as video system do draw the video to the screen. -Both can significant reduce the CPU load. - -En-/decoding with an NVIDIA GeForce 940MX also seems to work but there are issues when vaapi is also used as video system. diff -Nru voctomix-1.3+git20200101/voctocore/test-transition.py voctomix-1.3+git20200102/voctocore/test-transition.py --- voctomix-1.3+git20200101/voctocore/test-transition.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/test-transition.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,373 +0,0 @@ -#!/usr/bin/env python3 -from configparser import SafeConfigParser -from vocto.transitions import Composites, Transitions, L, T, R, B, X, Y -from PIL import Image, ImageDraw, ImageFont -# for integer maximum size -import sys -# for calling convert to generate animated GIF -from subprocess import call -import copy -import re -import logging -import argparse - - -def read_arguments(): - global Args - # read arguments - __all__ = ['Args'] - parser = argparse.ArgumentParser( - description='transition - tool to generate voctomix transition animations for testing') - parser.add_argument('composite', nargs='*', - help="list of composites to generate transitions between (use all available if not given)") - parser.add_argument('-f', '--config', action='store', default="voctocore/default-config.ini", - help="name of the configuration file to load") - parser.add_argument('-m', '--map', action='count', - help="print transition table") - parser.add_argument('-l', '--list', action='count', - help="list available composites") - parser.add_argument('-g', '--generate', action='count', - help="generate animation") - parser.add_argument('-t', '--title', action='count', default=0, - help="draw composite names and frame count") - parser.add_argument('-k', '--keys', action='count', default=0, - help="draw key frames") - parser.add_argument('-c', '--corners', action='count', default=0, - help="draw calculated interpolation corners") - parser.add_argument('-C', '--cross', action='count', default=0, - help="draw image cross through center") - parser.add_argument('-r', '--crop', action='count', default=0, - help="draw image cropping border") - parser.add_argument('-n', '--number', action='count', - help="when using -g: use consecutively numbers as file names") - parser.add_argument('-P', '--nopng', action='count', - help="when using -g: do not write PNG files (forces -G)") - parser.add_argument('-L', '--leave', action='count', - help="when using -g: do not delete temporary PNG files") - parser.add_argument('-G', '--nogif', action='count', - help="when using -g: do not generate animated GIFS") - parser.add_argument('-v', '--verbose', action='count', default=0, - help="also print WARNING (-v), INFO (-vv) and DEBUG (-vvv) messages") - parser.add_argument('-s', '--size', action='store', - help="set frame size 'WxH' W and H must be pixels") - parser.add_argument('-S', '--fps', '--speed', action='store', default=25, - help="animation resolution (frames per second)") - Args = parser.parse_args() - # implicit options - if Args.nopng: - Args.nogif = 1 - - -def init_log(): - global Args, log - # set up logging - FORMAT = '%(message)s' - logging.basicConfig(format=FORMAT) - logging.root.setLevel([logging.ERROR, logging.WARNING, - logging.INFO, logging.DEBUG][Args.verbose]) - log = logging.getLogger('Transitions Test') - - -def read_config(filename=None): - global log, Args - if not filename: - filename = Args.config - # load INI files - config = SafeConfigParser() - config.read(filename) - if Args.size: - r = re.match( - r'^\s*(\d+)\s*x\s*(\d+)\s*$', Args.size) - else: - r = re.match( - r'^.*width\s*=\s*(\d+).*height\s*=\s*(\d+).*$', config.get('mix', 'videocaps')) - if r: - size = [int(r.group(1)), int(r.group(2))] - # read frames per second - if Args.fps: - fps = int(Args.fps) - else: - r = re.match( - r'^\s*framerate\s*=\s*(\d+)/(\d+).*$', config.get('mix', 'videocaps')) - if r: - fps = float(r.group(1)) / float(r.group(2)) - print (size, fps) - # read composites from configuration - log.info("reading composites from configuration...") - composites = Composites.configure(config.items('composites'), size) - log.debug("read %d composites:\n\t%s\t" % - (len(composites), '\n\t'.join(sorted(composites)))) - # maybe overwirte targets by arguments - if Args.composite: - # check for composites in arguments - targets = [composites[c] for c in set(Args.composite)] - else: - # list of all relevant composites we like to target - targets = Composites.targets(composites) - intermediates = Composites.intermediates(composites) - # list targets and itermediates - if Args.list: - print("%d targetable composite(s):\n\t%s\t" % - (len(targets), '\n\t'.join([t.name for t in targets]))) - print("%d intermediate composite(s):\n\t%s\t" % - (len(intermediates), '\n\t'.join([t.name for t in intermediates]))) - # read transitions from configuration - log.info("reading transitions from configuration...") - transitions = Transitions.configure( - config.items('transitions'), composites, targets, fps) - log.info("read %d transition(s)" % transitions.count()) - if Args.map: - print("transition table:\n%s" % transitions) - # maybe overwirte targets by arguments - if Args.composite: - # check for composites in arguments - sequence = Args.composite - else: - # generate sequence of targets - sequence = Transitions.travel([t.name for t in targets]) - log.debug("using %d target composite(s):\n\t%s\t" % - (len(targets), '\n\t'.join([t.name for t in targets]))) - - # return config - return size, fps, sequence, transitions, composites - - -def draw_text(draw, size, line_or_pos, text, fill=(255, 255, 255, 255), align=0): - # get a font - font = ImageFont.truetype("FreeSans.ttf", - 11 if size[X] < 400 else - (13 if size[X] < 800 else - 20)) - if type(line_or_pos) == int: - assert not align - line_factor = 1.3 - line_height = font.getsize("|")[Y] * line_factor - # measure text size - x = (size[X] - draw.textsize(text, font=font)[X]) / 2 - if line_or_pos >= 0: - y = line_height * (line_or_pos - 1) + line_height * \ - (line_factor - 1.0) - else: - y = size[Y] + line_height * line_or_pos - else: - assert type(line_or_pos) == list - x, y = line_or_pos - if align == 0: - x = (x - draw.textsize(text, font=font)[X]) / 2 - elif align == -1: - x = x - draw.textsize(text, font=font)[X] - y = y - draw.textsize(text, font=font)[Y] - draw.text([x, y], text, font=font, fill=fill) - - -def draw_composite(size, composite, swap=False): - # indices in size and tsize - X, Y = 0, 1 - # create an image to draw into - imageBg = Image.new('RGBA', size, (40, 40, 40, 255)) - imageA = Image.new('RGBA', size, (0, 0, 0, 0)) - imageB = Image.new('RGBA', size, (0, 0, 0, 0)) - imageFg = Image.new('RGBA', size, (0, 0, 0, 0)) - # create a drawing context - drawBg = ImageDraw.Draw(imageBg) - drawA = ImageDraw.Draw(imageA) - drawB = ImageDraw.Draw(imageB) - drawFg = ImageDraw.Draw(imageFg) - if Args.cross: - # mark center lines - drawFg.line((size[X] / 2, 0, size[X] / 2, size[Y]), - fill=(0, 0, 0, 128)) - drawFg.line((0, size[Y] / 2, size[X], size[Y] / 2), - fill=(0, 0, 0, 128)) - - # simulate swapping sources - a, b = composite.A(), composite.B() - if swap: - a, b = b, a - if Args.title: - draw_text(drawFg, size, 2, "(swapped sources)") - - if Args.crop: - # draw source frame - drawA.rectangle(a.rect, outline=(128, 0, 0, a.alpha)) - drawB.rectangle(b.rect, outline=(0, 0, 128, b.alpha)) - # draw cropped source frame - drawA.rectangle(a.cropped(), fill=(128, 0, 0, a.alpha)) - drawB.rectangle(b.cropped(), fill=(0, 0, 128, b.alpha)) - - # silly way to draw on RGBA frame buffer, hey - it's python - return Image.alpha_composite( - Image.alpha_composite( - Image.alpha_composite(imageBg, imageA), imageB), imageFg) - - -def draw_transition(size, transition, info=None): - # animation as a list of images - images = [] - # render all frames - for i in range(transition.frames()): - # create an image to draw into - imageBg = draw_composite(size, transition.composites[i], - transition.flip is not None and i >= transition.flip) - imageDesc = Image.new('RGBA', size, (0, 0, 0, 0)) - imageFg = Image.new('RGBA', size, (0, 0, 0, 0)) - # create a drawing context - drawBg = ImageDraw.Draw(imageBg) - drawDesc = ImageDraw.Draw(imageDesc) - drawFg = ImageDraw.Draw(imageFg) - - acolor = (256, 128, 128, 128) - bcolor = (128, 128, 256, 128) - - if Args.keys: - n = 0 - for key in transition.keys(): - ac = key.A().rect - bc = key.B().rect - drawDesc.rectangle(ac, outline=acolor) - draw_text(drawDesc, size, - [ac[L] + 2, ac[T] + 2], - "A.%d" % n, acolor, 1) - drawDesc.rectangle(bc, outline=bcolor) - draw_text(drawDesc, size, - [bc[R] - 2, bc[B] - 2], - "B.%d" % n, bcolor, -1) - n += 1 - - if Args.corners: - # draw calculated corner points - for n in range(0, i + 1): - ar = transition.A(n).rect - br = transition.B(n).rect - drawDesc.rectangle( - (ar[R] - 2, ar[T] - 2, ar[R] + 2, ar[T] + 2), fill=acolor) - drawDesc.rectangle( - (br[L] - 2, br[T] - 2, br[L] + 2, br[T] + 2), fill=bcolor) - - drawDesc.rectangle( - (ar[L] - 1, ar[T] - 1, ar[L] + 1, ar[T] + 1), fill=acolor) - drawDesc.rectangle( - (ar[L] - 1, ar[B] - 1, ar[L] + 1, ar[B] + 1), fill=acolor) - drawDesc.rectangle( - (ar[R] - 1, ar[B] - 1, ar[R] + 1, ar[B] + 1), fill=acolor) - - drawDesc.rectangle( - (br[R] - 1, br[T] - 1, br[R] + 1, br[T] + 1), fill=bcolor) - drawDesc.rectangle( - (br[L] - 1, br[B] - 1, br[L] + 1, br[B] + 1), fill=bcolor) - drawDesc.rectangle( - (br[R] - 1, br[B] - 1, br[R] + 1, br[B] + 1), fill=bcolor) - - if Args.title: - draw_text(drawFg, size, -3, transition.name()) - if not info is None: - draw_text(drawFg, size, -2, info) - draw_text( - drawFg, size, -1, " → ".join([c.name for c in transition.keys()])) - draw_text(drawFg, size, 1, "Frame %d" % i) - # silly way to draw on RGBA frame buffer, hey - it's python - images.append( - Image.alpha_composite( - Image.alpha_composite(imageBg, imageDesc), imageFg) - ) - # return resulting animation images - return images - - -def save_transition_gif(filename, size, info, transition, time): - frames = transition.frames() - # save animation - log.info("generating transition '%s' (%d ms, %d frames)..." % - (transition.name(), int(time), frames)) - images = draw_transition(size, transition, info) - if not Args.nopng: - imagenames = [] - delay = int(time / 10.0 / frames) - log.info("saving animation frames into temporary files '%s0000.png'..'%s%04d.png'..." % - (filename, filename, transition.frames() - 1)) - for i in range(0, len(images)): - imagenames.append("%s%04d.png" % (filename, i)) - # save an image - images[i].save(imagenames[-1]) - # generate animated GIF by calling system command 'convert' - if not Args.nogif: - log.info("creating animated file '%s.gif' with delay %d..." % - (filename, delay)) - call(["convert", "-loop", "0"] + - ["-delay", "100"] + imagenames[:1] + - ["-delay", "%d" % delay] + imagenames[1:-1] + - ["-delay", "100"] + imagenames[-1:] + - ["%s.gif" % filename]) - # delete temporary files? - if not Args.leave: - log.info("deleting temporary PNG files...") - call(["rm"] + imagenames) - - -def render_composites(size, composites): - global log - log.debug("rendering composites (%d items):\n\t%s\t" % - (len(composites), '\n\t'.join([c.name for c in composites]))) - for c in composites: - if Args.generate: - print("saving composite file '%s.png' (%s)..." % (c.name, c.name)) - draw_composite(size, c).save("%s.png" % c.name) - - -def render_sequence(size, fps, sequence, transitions, composites): - global log - log.debug("rendering generated sequence (%d items):\n\t%s\t" % - (len(sequence), '\n\t'.join(sequence))) - - # begin at first transition - prev_name = sequence[0] - prev = composites[prev_name] - # cound findings - not_found = [] - found = [] - # process sequence through all possible transitions - for c_name in sequence[1:]: - # fetch prev composite - c = composites[c_name] - # get the right transtion between prev and c - log.debug("request transition (%d/%d): %s → %s" % - (len(found) + 1, len(sequence) - 1, prev_name, c_name)) - # actually search for a transitions that does a fade between prev and c - transition, swap = transitions.solve(prev, c, prev == c) - # count findings - if not transition: - # report fetched transition - log.warning("no transition found for: %s → %s" % - (prev_name, c_name)) - not_found.append("%s -> %s" % (prev_name, c_name)) - else: - # report fetched transition - log.debug("transition found: %s\n%s" % - (transition.name(), transition)) - found.append(transition.name()) - # get sequence frames - frames = transition.frames() - if Args.generate: - filename = ("%03d" % len( - found)) if Args.number else "%s_%s" % (prev_name, c_name) - print("saving transition animation file '%s.gif' (%s, %d frames)..." % - (filename, transition.name(), frames)) - # generate test images for transtion and save into animated GIF - save_transition_gif(filename, size, "%s → %s" % (prev_name, c_name), - transition, frames / fps * 1000.0) - # remember current transition as next previous - prev_name, prev = c_name, c - # report findings - if found: - if Args.list: - print("%d transition(s) available:\n\t%s" % - (len(found), '\n\t'.join(sorted(found)))) - if not_found: - print("%d transition(s) could NOT be found:\n\t%s" % - (len(not_found), "\n\t".join(sorted(not_found)))) - -read_arguments() -init_log() -cfg = read_config() -render_composites(cfg[0], Composites.targets(cfg[4])) -render_sequence(*cfg) diff -Nru voctomix-1.3+git20200101/voctocore/voctocore.py voctomix-1.3+git20200102/voctocore/voctocore.py --- voctomix-1.3+git20200101/voctocore/voctocore.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctocore/voctocore.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,32 +1,21 @@ #!/usr/bin/env python3 import gi -import sdnotify import signal import logging import sys -import os - -sys.path.insert(0, '.') -from vocto.debug import gst_log_messages # import GStreamer and GLib-Helper classes gi.require_version('Gst', '1.0') gi.require_version('GstNet', '1.0') -from gi.repository import Gst, GLib +from gi.repository import Gst, GObject # import local classes from lib.loghandler import LogHandler -from lib.debug import gst_log_messages - # check min-version minGst = (1, 5) minPy = (3, 0) -# set GST debug dir for dot files -if not 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: - os.environ['GST_DEBUG_DUMP_DOT_DIR'] = os.getcwd() - Gst.init([]) if Gst.version() < minGst: raise Exception('GStreamer version', Gst.version(), @@ -37,6 +26,10 @@ 'is too old, at least', minPy, 'is required') +# init GObject & Co. before importing local classes +GObject.threads_init() + + # main class class Voctocore(object): @@ -48,25 +41,25 @@ from lib.controlserver import ControlServer self.log = logging.getLogger('Voctocore') - self.log.debug('Creating GLib-MainLoop') - self.mainloop = GLib.MainLoop() + self.log.debug('creating GObject-MainLoop') + self.mainloop = GObject.MainLoop() # initialize subsystem - self.log.debug('Creating A/V-Pipeline') + self.log.debug('creating A/V-Pipeline') self.pipeline = Pipeline() - self.log.debug('Creating ControlServer') + self.log.debug('creating ControlServer') self.controlserver = ControlServer(self.pipeline) def run(self): - self.log.info('Running. Waiting for connections....') + self.log.info('running GObject-MainLoop') try: self.mainloop.run() except KeyboardInterrupt: self.log.info('Terminated via Ctrl-C') def quit(self): - self.log.info('Quitting.') + self.log.info('quitting GObject-MainLoop') self.mainloop.quit() @@ -83,14 +76,14 @@ handler = LogHandler(docolor, Args.timestamp) logging.root.addHandler(handler) - levels = { 3 : logging.DEBUG, 2 : logging.INFO, 1 : logging.WARNING, 0 : logging.ERROR } - logging.root.setLevel(levels[Args.verbose]) - - gst_levels = { 3 : Gst.DebugLevel.DEBUG, 2 : Gst.DebugLevel.INFO, 1 : Gst.DebugLevel.WARNING, 0 : Gst.DebugLevel.ERROR } - gst_log_messages(gst_levels[Args.gstreamer_log]) + if Args.verbose >= 2: + level = logging.DEBUG + elif Args.verbose == 1: + level = logging.INFO + else: + level = logging.WARNING - gst_levels = { 3 : Gst.DebugLevel.DEBUG, 2 : Gst.DebugLevel.INFO, 1 : Gst.DebugLevel.WARNING, 0 : Gst.DebugLevel.ERROR } - gst_log_messages(gst_levels[Args.gstreamer_log]) + logging.root.setLevel(level) # make killable by ctrl-c logging.debug('setting SIGINT handler') @@ -107,11 +100,6 @@ logging.debug('initializing Voctocore') voctocore = Voctocore() - # Inform systemd that we are ready - # for use with the notify service type - n = sdnotify.SystemdNotifier() - n.notify("READY=1") - logging.debug('running Voctocore') voctocore.run() diff -Nru voctomix-1.3+git20200101/voctogui/default-config.ini voctomix-1.3+git20200102/voctogui/default-config.ini --- voctomix-1.3+git20200101/voctogui/default-config.ini 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/default-config.ini 2020-01-03 00:02:24.000000000 +0000 @@ -1,20 +1,22 @@ [server] host=localhost -#host=10.73.23.3 [previews] width=320 ;height=180 -;nameoverlay=false ; true = use if the server provides it ; false = never use it use=true -[videodisplay] -# Use VAAPI's renderer, requires VAAPI-based previews -#system=vaapi +[mainvideo] +# disabled by default because it seems there's an issue, see +# https://github.com/voc/voctomix/issues/37 +# number of vu meters to display, set vumeter=all to to show VU meters of all audiostreams in gui +vumeter=1 +playaudio=false +[videodisplay] # Use OpenGL - most performant #system=gl @@ -24,17 +26,13 @@ # Use simple X-Images - least performant system=x -[toolbar] +[misc] close=true -ports=true -queues=true -fullscreen=true +cut=true [audio] ; Show volume controls even if a default audio source is set ;forcevolumecontrol=true -; play audio by default -play=false [mainwindow] # resize main window @@ -42,4 +40,3 @@ ;height=1000 # force main window to stay full screen ;forcefullscreen=true -vumeter=all Binary files /tmp/tmp1oKQ1w/kC1cu3RtDA/voctomix-1.3+git20200101/voctogui/doc/images/voc2gui.png and /tmp/tmp1oKQ1w/IxNh0GKohY/voctomix-1.3+git20200102/voctogui/doc/images/voc2gui.png differ diff -Nru voctomix-1.3+git20200101/voctogui/lib/args.py voctomix-1.3+git20200102/voctogui/lib/args.py --- voctomix-1.3+git20200101/voctogui/lib/args.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/args.py 2020-01-03 00:02:24.000000000 +0000 @@ -10,7 +10,7 @@ parser = argparse.ArgumentParser(description='Voctogui') parser.add_argument('-v', '--verbose', action='count', default=0, - help="Set verbosity level by using -v, -vv or -vvv.") + help="Also print INFO and DEBUG messages.") parser.add_argument('-c', '--color', action='store', @@ -22,19 +22,13 @@ help="Enable timestamps in the Log-Output") parser.add_argument('-i', '--ini-file', action='store', - help="Load a custom configuration file") + help="Load a custom config.ini-File") + + parser.add_argument('-u', '--ui-file', action='store', + help="Load a custom .ui-File") parser.add_argument('-H', '--host', action='store', help="Connect to this host " "instead of the configured one.") - parser.add_argument('-d', '--dot', action='store_true', - help="Generate DOT files of pipelines into directory given in environment variable GST_DEBUG_DUMP_DOT_DIR") - - parser.add_argument('-D', '--gst-debug-details', action='store', default=15, - help="Set details in dot graph. GST_DEBUG_DETAILS must be a combination the following values: 1 = show caps-name on edges, 2 = show caps-details on edges, 4 = show modified parameters on elements, 8 = show element states, 16 = show full element parameter values even if they are very long. Default: 15 = show all the typical details that one might want (15=1+2+4+8)") - - parser.add_argument('-g', '--gstreamer-log', action='count', default=0, - help="Log gstreamer messages into voctocore log (Set log level by using -g, -gg or -ggg).") - Args = parser.parse_args() diff -Nru voctomix-1.3+git20200101/voctogui/lib/audiodisplay.py voctomix-1.3+git20200102/voctogui/lib/audiodisplay.py --- voctomix-1.3+git20200101/voctogui/lib/audiodisplay.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/audiodisplay.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import json -import math -from configparser import NoOptionError - -from gi.repository import Gtk, Gdk, GObject -import lib.connection as Connection - -from lib.config import Config -from vocto.port import Port - -class AudioDisplay(object): - - def __init__(self, audio_box, source, uibuilder, has_volume=True): - self.log = logging.getLogger('VideoPreviewsController') - self.source = source - self.panel = None - self.panels = dict() - self.audio_streams = None - self.volume_sliders = {} - if source in Config.getSources(): - self.audio_streams = Config.getAudioStreams().get_source_streams(source) - for name, stream in self.audio_streams.items(): - self.panels[name] = self.createAudioPanel( - name, audio_box, has_volume, uibuilder) - else: - self.panel = self.createAudioPanel(source, audio_box, has_volume, uibuilder) - - def createAudioPanel(self, name, audio_box, has_volume, uibuilder): - audio = uibuilder.load_check_widget('audio', - os.path.dirname(uibuilder.uifile) + - "/audio.ui") - audio_box.pack_start(audio, fill=False, - expand=False, padding=0) - audio_label = uibuilder.find_widget_recursive(audio, 'audio_label') - audio_label.set_label(name.upper()) - - self.init_volume_slider(name, audio, has_volume, uibuilder) - - return {"level": uibuilder.find_widget_recursive(audio, 'audio_level_display')} - - def callback(self, rms, peak, decay): - if self.audio_streams: - for name, streams in self.audio_streams.items(): - _rms = [0] * len(streams) - _peak = [0] * len(streams) - _decay = [0] * len(streams) - for stream in streams: - _rms[stream.channel] = rms[stream.source_channel] - _peak[stream.channel] = peak[stream.source_channel] - _decay[stream.channel] = decay[stream.source_channel] - self.panels[name]["level"].level_callback(_rms, _peak, _decay) - elif self.panel: - self.panel["level"].level_callback(rms, peak, decay) - - def init_volume_slider(self, name, audio_box, has_volume, uibuilder): - volume_slider = uibuilder.find_widget_recursive(audio_box, - 'audio_level') - - if has_volume: - volume_signal = volume_slider.connect('value-changed', - self.slider_changed) - volume_slider.set_name(name) - volume_slider.add_mark(-20.0, Gtk.PositionType.LEFT, "") - volume_slider.add_mark(0.0, Gtk.PositionType.LEFT, "0") - volume_slider.add_mark(10.0, Gtk.PositionType.LEFT, "") - - def slider_format(scale, value): - if value == -20.0: - return "-\u221e\u202fdB" - else: - return "{:.{}f}\u202fdB".format(value, - scale.get_digits()) - volume_slider.connect('format-value', slider_format) - self.volume_sliders[name] = (volume_slider, volume_signal) - if not Config.getVolumeControl(): - volume_slider.set_sensitive(False) - - Connection.on('audio_status', self.on_audio_status) - Connection.send('get_audio') - else: - volume_slider.set_no_show_all(True) - volume_slider.hide() - - def slider_changed(self, slider): - stream = slider.get_name() - value = slider.get_value() - volume = 10 ** (value / 20) if value > -20.0 else 0 - self.log.debug("slider_changed: {}: {:.4f}".format(stream, volume)) - Connection.send('set_audio_volume {} {:.4f}'.format(stream, volume)) - - def on_audio_status(self, *volumes): - volumes_json = "".join(volumes) - volumes = json.loads(volumes_json) - - for stream, volume in volumes.items(): - if stream in self.volume_sliders: - volume = 20.0 * math.log10(volume) if volume > 0 else -20.0 - slider, signal = self.volume_sliders[stream] - # Temporarily block the 'value-changed' signal, - # so we don't (re)trigger it when receiving (our) changes - GObject.signal_handler_block(slider, signal) - slider.set_value(volume) - GObject.signal_handler_unblock(slider, signal) diff -Nru voctomix-1.3+git20200101/voctogui/lib/audioleveldisplay.py voctomix-1.3+git20200102/voctogui/lib/audioleveldisplay.py --- voctomix-1.3+git20200101/voctogui/lib/audioleveldisplay.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/audioleveldisplay.py 2020-01-03 00:02:24.000000000 +0000 @@ -9,10 +9,6 @@ """Displays a Level-Meter of another VideoDisplay into a GtkWidget""" __gtype_name__ = 'AudioLevelDisplay' - MARGIN = 4 - CHANNEL_WIDTH = 8 - LABEL_WIDTH = 20 - def __init__(self): self.num_audiostreams_ = int(Config.get('mix', 'audiostreams')) meters = Config.get('mainvideo', 'vumeter') @@ -54,9 +50,20 @@ if channels == 0: return False - yoff = 3 width = self.get_allocated_width() - height = self.get_allocated_height()-yoff + height = self.get_allocated_height() + + # space between the channels in px + margin = 2 + + # 1 channel -> 0 margins, 2 channels -> 1 margin, 3 channels… + channel_width = int((width - (margin * (channels - 1))) / channels) + + # self.log.debug( + # 'width: %upx filled with %u channels of each %upx ' + # 'and %ux margin of %upx', + # width, channels, channel_width, channels - 1, margin + # ) # normalize db-value to 0…1 and multiply with the height rms_px = [self.normalize_db(db) * height for db in self.levelrms] @@ -71,48 +78,54 @@ self.peak_lg = self.gradient(0.75, 0.0, height) self.decay_lg = self.gradient(1.0, 0.5, height) - first_col = True # draw all level bars for all channels for channel in range(0, channels): # start-coordinate for this channel - x = (channel * self.CHANNEL_WIDTH) + (channel * self.MARGIN) + self.LABEL_WIDTH + x = (channel * channel_width) + (channel * margin) # draw background - cr.rectangle(x, yoff, self.CHANNEL_WIDTH, height - peak_px[channel]) + cr.rectangle(x, 0, channel_width, height - peak_px[channel]) cr.set_source(self.bg_lg) cr.fill() # draw peak bar cr.rectangle( - x, yoff +height - peak_px[channel], self.CHANNEL_WIDTH, peak_px[channel]) + x, height - peak_px[channel], channel_width, peak_px[channel]) cr.set_source(self.peak_lg) cr.fill() # draw rms bar below cr.rectangle( - x, yoff + height - rms_px[channel], self.CHANNEL_WIDTH, + x, height - rms_px[channel], channel_width, rms_px[channel] - peak_px[channel]) cr.set_source(self.rms_lg) cr.fill() # draw decay bar - cr.rectangle(x, yoff + height - decay_px[channel], self.CHANNEL_WIDTH, 2) + cr.rectangle(x, height - decay_px[channel], channel_width, 2) cr.set_source(self.decay_lg) cr.fill() - first_col = False - - # draw db text-markers - for db in [-40, -20, -10, -5, -4, -3, -2, -1]: - text = str(db) - (xbearing, ybearing, - textwidth, textheight, - xadvance, yadvance) = cr.text_extents(text) - - y = self.normalize_db(db) * height + # draw medium grey margin bar + if margin > 0: + cr.rectangle(x + channel_width, 0, margin, height) cr.set_source_rgb(0.5, 0.5, 0.5) - cr.move_to(self.LABEL_WIDTH - textwidth - 4, yoff + height - y - textheight) - cr.show_text(text) + cr.fill() + + # draw db text-markers + for db in [-40, -20, -10, -5, -4, -3, -2, -1]: + text = str(db) + (xbearing, ybearing, + textwidth, textheight, + xadvance, yadvance) = cr.text_extents(text) + + y = self.normalize_db(db) * height + if y > peak_px[channels - 1]: + cr.set_source_rgb(1, 1, 1) + else: + cr.set_source_rgb(0, 0, 0) + cr.move_to((width - textwidth) - 2, height - y - textheight) + cr.show_text(text) return True @@ -128,12 +141,10 @@ def clamp(self, value, min_value=0, max_value=1): return max(min(value, max_value), min_value) - def level_callback(self, rms, peak, decay): - if self.levelrms != rms or self.levelpeak != peak \ - or self.leveldecay != decay: - self.levelrms = rms - self.levelpeak = peak - self.leveldecay = decay - - self.set_size_request(len(self.levelrms) * (self.CHANNEL_WIDTH + self.MARGIN) + self.LABEL_WIDTH, 100) - self.queue_draw() + def level_callback(self, rms, peak, decay, stream): + meter_offset = self.channels * stream + for i in range(0, self.channels): + self.levelrms[meter_offset + i] = rms[i] + self.levelpeak[meter_offset + i] = peak[i] + self.leveldecay[meter_offset + i] = decay[i] + self.queue_draw() diff -Nru voctomix-1.3+git20200101/voctogui/lib/clock.py voctomix-1.3+git20200102/voctogui/lib/clock.py --- voctomix-1.3+git20200101/voctogui/lib/clock.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/clock.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,9 +1,5 @@ #!/usr/bin/python3 import logging -import gi - -gi.require_version('GstNet', '1.0') - from gi.repository import Gst, GstNet __all__ = ['Clock'] diff -Nru voctomix-1.3+git20200101/voctogui/lib/config.py voctomix-1.3+git20200102/voctogui/lib/config.py --- voctomix-1.3+git20200101/voctogui/lib/config.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/config.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,19 +1,25 @@ -#!/usr/bin/env python3 import os.path import logging +from configparser import SafeConfigParser from lib.args import Args import lib.connection as Connection -from vocto.config import VocConfigParser + __all__ = ['Config'] Config = None -log = logging.getLogger('VoctoguiConfigParser') +class VocConfigParser(SafeConfigParser): + def getlist(self, section, option): + option = self.get(section, option).strip() + if len(option) == 0: + return [] -class VoctoguiConfigParser(VocConfigParser): + unfiltered = [x.strip() for x in option.split(',')] + return list(filter(None, unfiltered)) def fetchServerConfig(self): + log = logging.getLogger('Config') log.info("reading server-config") server_config = Connection.fetchServerConfig() @@ -21,83 +27,37 @@ log.info("merging server-config %s", server_config) self.read_dict(server_config) - def getHost(self): - return Args.host if Args.host else self.get('server', 'host') - - def getWindowSize(self): - if self.has_option('mainwindow', 'width') \ - and self.has_option('mainwindow', 'height'): - # get size from config - return (self.getint('mainwindow', 'width'), - self.getint('mainwindow', 'height')) - else: - return None - - def getForceFullScreen(self): - return self.getboolean('mainwindow', 'forcefullscreen', fallback=False) - - def getShowCloseButton(self): - return self.getboolean('toolbar', 'close', fallback=True) - - def getShowFullScreenButton(self): - return self.getboolean('toolbar', 'fullscreen', fallback=False) - - def getShowQueueButton(self): - return self.getboolean('toolbar', 'queues', fallback=False) - - def getShowPortButton(self): - return self.getboolean('toolbar', 'ports', fallback=True) - - def getToolbarSourcesDefault(self): - return {"%s.name" % source: - source.upper() - for source in self.getList('mix', 'sources') - } - - def trySection(self, section_name, default_result=None): - return self[section_name] if self.has_section(section_name) else default_result - - def getToolbarSourcesA(self): - return self.trySection('toolbar.sources.a', self.getToolbarSourcesDefault()) - - def getToolbarSourcesB(self): - return self.trySection('toolbar.sources.b', self.getToolbarSourcesDefault()) - - def getToolbarCompositesDefault(self): - return {"%s.name" % composite.name: - composite.name.upper() - for composite in self.getTargetComposites() - } - - def getToolbarComposites(self): - return self.trySection('toolbar.composites', self.getToolbarCompositesDefault()) - - def getToolbarMods(self): - return self.trySection('toolbar.mods', {}) - - def getToolbarMixDefault(self): - return {"retake.name": "RETAKE", - "cut.name": "CUT", - "trans.name": "TRANS" - } - - def getToolbarMix(self): - return self.trySection('toolbar.mix', self.getToolbarMixDefault()) - - def getToolbarInsert(self): - return self.trySection('toolbar.insert', {}) - def load(): global Config - Config = VoctoguiConfigParser() - - config_file_name = Args.ini_file if Args.ini_file else os.path.join( - os.path.dirname(os.path.realpath(__file__)), '../default-config.ini') - readfiles = Config.read([config_file_name]) - - log.debug("successfully parsed config-file: '%s'", config_file_name) + files = [ + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../default-config.ini'), + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../config.ini'), + '/etc/voctomix/voctogui.ini', + '/etc/voctogui.ini', + os.path.expanduser('~/.voctogui.ini'), + ] + + if Args.ini_file is not None: + files.append(Args.ini_file) + + Config = VocConfigParser() + readfiles = Config.read(files) + + log = logging.getLogger('ConfigParser') + log.debug('considered config-files: \n%s', + "\n".join([ + "\t\t" + os.path.normpath(file) + for file in files + ])) + log.debug('successfully parsed config-files: \n%s', + "\n".join([ + "\t\t" + os.path.normpath(file) + for file in readfiles + ])) if Args.ini_file is not None and Args.ini_file not in readfiles: raise RuntimeError('explicitly requested config-file "{}" ' diff -Nru voctomix-1.3+git20200101/voctogui/lib/connection.py voctomix-1.3+git20200102/voctogui/lib/connection.py --- voctomix-1.3+git20200101/voctogui/lib/connection.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/connection.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,30 +1,23 @@ import logging import socket import json -import sys - from queue import Queue from gi.repository import Gtk, GObject -from vocto.port import Port - log = logging.getLogger('Connection') conn = None ip = None +port = 9999 command_queue = Queue() signal_handlers = {} def establish(host): - global conn, log, ip + global conn, port, log, ip log.info('establishing Connection to %s', host) - try: - conn = socket.create_connection((host, Port.CORE_LISTENING)) - log.info("Connection to host %s at port %d successful" % (host, Port.CORE_LISTENING) ) - except ConnectionRefusedError: - log.error("Connecting to %s at port %d has failed. Is voctocore running? Can you ping the host?" % (host, Port.CORE_LISTENING) ) - sys.exit(-1) + conn = socket.create_connection((host, port)) + log.debug(r'Connection successful \o/') ip = conn.getpeername()[0] log.debug('Remote-IP is %s', ip) @@ -124,8 +117,8 @@ signal = words[0] args = words[1:] - if signal == "error": - log.error('received error: %s', line ) + + log.info('received signal %s, dispatching', signal) if signal not in signal_handlers: return True diff -Nru voctomix-1.3+git20200101/voctogui/lib/__init__.py voctomix-1.3+git20200102/voctogui/lib/__init__.py --- voctomix-1.3+git20200101/voctogui/lib/__init__.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/__init__.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,3 +0,0 @@ -import sys -sys.path.insert(0, '..') -sys.path.insert(0, '.') diff -Nru voctomix-1.3+git20200101/voctogui/lib/loghandler.py voctomix-1.3+git20200102/voctogui/lib/loghandler.py --- voctomix-1.3+git20200101/voctogui/lib/loghandler.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/loghandler.py 2020-01-03 00:02:24.000000000 +0000 @@ -15,18 +15,12 @@ c_mod = 32 c_msg = 0 - if record.levelno <= logging.DEBUG: - c_msg = 90 - - elif record.levelno <= logging.INFO: - c_lvl = 37 - c_msg = 97 - - elif record.levelno <= logging.WARNING: + if record.levelno == logging.WARNING: c_lvl = 31 + # c_mod = 33 c_msg = 33 - else: + elif record.levelno > logging.WARNING: c_lvl = 31 c_mod = 31 c_msg = 31 diff -Nru voctomix-1.3+git20200101/voctogui/lib/ports.py voctomix-1.3+git20200102/voctogui/lib/ports.py --- voctomix-1.3+git20200101/voctogui/lib/ports.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/ports.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import json -from gi.repository import Gtk, Gst, GLib - -from lib.config import Config -from lib.uibuilder import UiBuilder -import lib.connection as Connection -from vocto.port import Port - -# time interval to re-fetch queue timings -TIMER_RESOLUTION = 5.0 - -COLOR_OK = ("white", "darkgreen") -COLOR_WARN = ("darkred", "darkorange") -COLOR_ERROR = ("white", "red") - - -class PortsWindowController(): - - def __init__(self, uibuilder): - self.log = logging.getLogger('QueuesWindowController') - - # get related widgets - self.win = uibuilder.get_check_widget('ports_win') - self.store = uibuilder.get_check_widget('ports_store') - self.scroll = uibuilder.get_check_widget('ports_scroll') - self.title = uibuilder.get_check_widget('ports_title') - self.title.set_title("VOC2CORE {}".format(Config.getHost())) - # remember row iterators - self.iterators = None - - # listen for queue_report from voctocore - Connection.on('port_report', self.on_port_report) - - def on_port_report(self, *report): - - def color(port): - if port.connections > 0: - return COLOR_OK - else: - return COLOR_ERROR if port.is_input() else COLOR_WARN - - # read string report into dictonary - report = json.loads("".join(report)) - # check if this is the initial report - if not self.iterators: - # append report as rows to treeview store and remember row iterators - self.iterators = dict() - for p in report: - port = Port.from_str(p) - self.iterators[port.port] = self.store.append(( - port.name, - port.audio, - port.video, - "IN" if port.is_input() else "OUT", - port.port, - *color(port) - )) - else: - # just update values of second column - for p in report: - port = Port.from_str(p) - it = self.iterators[port.port] - self.store.set_value(it, 0, port.name) - self.store.set_value(it, 1, port.audio) - self.store.set_value(it, 2, port.video) - self.store.set_value(it, 3, "IN" if port.is_input() else "OUT") - self.store.set_value(it, 4, port.port) - self.store.set_value(it, 5, color(port)[0]) - self.store.set_value(it, 6, color(port)[1]) - - def show(self, visible=True): - # check if widget is getting visible - if visible: - # request queue timing report from voctocore - Connection.send('report_ports') - # schedule repetition - GLib.timeout_add(TIMER_RESOLUTION * 1000, self.do_timeout) - # do the boring stuff - self.win.show() - else: - self.win.hide() - - def do_timeout(self): - # re-request queue report - Connection.send('report_ports') - # repeat if widget is visible - return self.win.is_visible() diff -Nru voctomix-1.3+git20200101/voctogui/lib/queues.py voctomix-1.3+git20200102/voctogui/lib/queues.py --- voctomix-1.3+git20200101/voctogui/lib/queues.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/queues.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import json -from gi.repository import Gtk, Gst, GLib - -from lib.config import Config -from lib.uibuilder import UiBuilder -import lib.connection as Connection - -# time interval to re-fetch queue timings -TIMER_RESOLUTION = 1.0 - -class QueuesWindowController(): - - def __init__(self,uibuilder): - self.log = logging.getLogger('QueuesWindowController') - - # get related widgets - self.win = uibuilder.get_check_widget('queue_win') - self.store = uibuilder.get_check_widget('queue_store') - self.scroll = uibuilder.get_check_widget('queue_scroll') - - # remember row iterators - self.iterators = None - - # listen for queue_report from voctocore - Connection.on('queue_report', self.on_queue_report) - - def on_queue_report(self, *report): - # read string report into dictonary - report = json.loads("".join(report)) - # check if this is the initial report - if not self.iterators: - # append report as rows to treeview store and remember row iterators - self.iterators = dict() - for queue, time in report.items(): - self.iterators[queue] = self.store.append((queue, time / Gst.SECOND)) - else: - # just update values of second column - for queue, time in report.items(): - self.store.set_value(self.iterators[queue], 1, time / Gst.SECOND) - - def show(self,visible=True): - # check if widget is getting visible - if visible: - # request queue timing report from voctocore - Connection.send('report_queues') - # schedule repetition - GLib.timeout_add(TIMER_RESOLUTION * 1000, self.do_timeout) - # do the boring stuff - self.win.show() - else: - self.win.hide() - - def do_timeout(self): - # re-request queue report - Connection.send('report_queues') - # repeat if widget is visible - return self.win.is_visible() diff -Nru voctomix-1.3+git20200101/voctogui/lib/shortcuts.py voctomix-1.3+git20200102/voctogui/lib/shortcuts.py --- voctomix-1.3+git20200101/voctogui/lib/shortcuts.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/shortcuts.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,83 @@ +from gi.repository import Gtk + +from lib.config import Config + + +if hasattr(Gtk, "ShortcutsWindow"): + def show_shortcuts(win): + shortcuts_window = ShortcutsWindow(win) + shortcuts_window.show() + + class ShortcutsWindow(Gtk.ShortcutsWindow): + def __init__(self, win): + Gtk.ShortcutsWindow.__init__(self) + self.build() + self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + self.set_transient_for(win) + self.set_modal(True) + + def build(self): + section = Gtk.ShortcutsSection() + section.show() + + compose_group = Gtk.ShortcutsGroup(title="Composition modes") + compose_group.show() + for accel, desc in [("F1", "Select fullscreen mode"), + ("F2", "Select Picture in Picture mode"), + ("F3", "Select Side-by-Side Equal mode"), + ("F4", "Select Side-by-Side Preview mode")]: + short = Gtk.ShortcutsShortcut(title=desc, accelerator=accel) + short.show() + compose_group.add(short) + section.add(compose_group) + + if Config.getboolean('stream-blanker', 'enabled'): + blank_group = self._build_blank_group() + section.add(blank_group) + + source_group = Gtk.ShortcutsGroup(title="Source Selection") + source_group.show() + num = len(Config.getlist('mix', 'sources')) + source_items = [ + ("1...{}".format(num), + "Select Source as A-Source"), + ("1...{}".format(num), + "Select Source as B-Source"), + ("1...{}".format(num), + "Select Source as Fullscreen") + ] + for accel, desc in source_items: + short = Gtk.ShortcutsShortcut(title=desc, accelerator=accel) + short.show() + source_group.add(short) + section.add(source_group) + + if Config.getboolean('misc', 'cut'): + other_group = Gtk.ShortcutsGroup(title="Other") + other_group.show() + short = Gtk.ShortcutsShortcut(title="Send Cut message", + accelerator="t") + short.show() + other_group.add(short) + section.add(other_group) + + self.add(section) + + def _build_blank_group(self): + blank_group = Gtk.ShortcutsGroup(title="Stream blanking") + blank_group.show() + blank_sources = Config.getlist('stream-blanker', 'sources') + blank_items = [ + ("F{}".format(12 - len(blank_sources) + i), + "Set stream to {}".format(source)) + for i, source in enumerate(reversed(blank_sources)) + ] + blank_items.append(("F12", "Set stream Live")) + for accel, desc in blank_items: + short = Gtk.ShortcutsShortcut(title=desc, accelerator=accel) + short.show() + blank_group.add(short) + return blank_group +else: + def show_shortcuts(win): + pass diff -Nru voctomix-1.3+git20200101/voctogui/lib/studioclock.py voctomix-1.3+git20200102/voctogui/lib/studioclock.py --- voctomix-1.3+git20200101/voctogui/lib/studioclock.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/studioclock.py 2020-01-03 00:02:24.000000000 +0000 @@ -45,9 +45,9 @@ # setup gradients for clock background to get a smooth border bg_lg = cairo.RadialGradient( center[0], center[1], 0, center[0], center[1], radius) - bg_lg.add_color_stop_rgba(0.0, 0.1, 0.1, 0.1, 1.0) - bg_lg.add_color_stop_rgba(0.9, 0.1, 0.1, 0.1, 1.0) - bg_lg.add_color_stop_rgba(1.0, 0.2, 0.2, 0.2, 0.0) + bg_lg.add_color_stop_rgba(0.0, 0, 0, 0, 1.0) + bg_lg.add_color_stop_rgba(0.9, 0, 0, 0, 1.0) + bg_lg.add_color_stop_rgba(1.0, 0, 0, 0, 0.0) # paint background cr.set_source(bg_lg) cr.arc(center[0], center[1], radius, 0, 2 * math.pi) @@ -78,8 +78,7 @@ cr.arc(pos[0], pos[1], radius / 40, 0, 2 * math.pi) cr.fill() # set a reasonable font size - cr.select_font_face("Roboto Condensed") - cr.set_font_size(cr.user_to_device_distance(0, height / 4)[1]) + cr.set_font_size(cr.user_to_device_distance(0, height / 5)[1]) # format time into a string text = time.strftime("%H:%M") # get text drawing extents diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/blinder.py voctomix-1.3+git20200102/voctogui/lib/toolbar/blinder.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/blinder.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/blinder.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os - -import time - -from gi.repository import Gtk, GLib -import lib.connection as Connection - -from lib.config import Config - - -class BlinderToolbarController(object): - """Manages Accelerators and Clicks on the Composition Toolbar-Buttons""" - - # set resolution of the blink timer in seconds - timer_resolution = 1.0 - - def __init__(self, win, uibuilder): - self.log = logging.getLogger('BlinderToolbarController') - self.toolbar = uibuilder.find_widget_recursive(win, 'toolbar_blinder') - - live_button = uibuilder.find_widget_recursive(self.toolbar, 'stream_live') - blind_button = uibuilder.find_widget_recursive( - self.toolbar, 'stream_blind') - blinder_box = uibuilder.find_widget_recursive( - win, 'box_blinds') - - blind_button_pos = self.toolbar.get_item_index(blind_button) - - if not Config.getBlinderEnabled(): - self.log.info('disabling blinding features ' - 'because the server does not support them') - - self.toolbar.remove(live_button) - self.toolbar.remove(blind_button) - - # hide blinder box - blinder_box.hide() - blinder_box.set_no_show_all(True) - return - - blinder_sources = Config.getBlinderSources() - - self.current_status = None - - live_button.connect('toggled', self.on_btn_toggled) - live_button.set_can_focus(False) - self.live_button = live_button - self.blind_buttons = {} - - accel_f_key = 11 - - for idx, name in enumerate(blinder_sources): - if idx == 0: - new_btn = blind_button - else: - new_btn = Gtk.RadioToolButton(group=live_button) - self.toolbar.insert(new_btn, blind_button_pos) - - new_btn.set_name(name) - new_btn.get_style_context().add_class("output") - new_btn.get_style_context().add_class("mode") - new_btn.set_can_focus(False) - new_btn.set_label(name.upper()) - new_btn.connect('toggled', self.on_btn_toggled) - new_btn.set_tooltip_text("Stop streaming by %s" % name) - - self.blind_buttons[name] = new_btn - accel_f_key = accel_f_key - 1 - - # connect event-handler and request initial state - Connection.on('stream_status', self.on_stream_status) - Connection.send('get_stream_status') - self.timeout = None - - def start_blink(self): - self.blink = True - self.do_timeout() - self.blink = True - # remove old time out - if self.timeout: - GLib.source_remove(self.timeout) - # set up timeout for periodic redraw - self.timeout = GLib.timeout_add_seconds(self.timer_resolution, self.do_timeout) - - def on_btn_toggled(self, btn): - if btn.get_active(): - btn_name = btn.get_name() - if self.current_status != btn_name: - self.log.info('stream-status activated: %s', btn_name) - if btn_name == 'live': - Connection.send('set_stream_live') - else: - Connection.send('set_stream_blind', btn_name) - - def on_stream_status(self, status, source=None): - self.log.info('on_stream_status callback w/ status %s and source %s', - status, source) - - self.current_status = source if source is not None else status - for button in list(self.blind_buttons.values()) + [self.live_button]: - if button.get_name() == self.current_status: - button.set_active(True) - self.start_blink() - - def do_timeout(self): - # if time did not change since last redraw - for button in list(self.blind_buttons.values()) + [self.live_button]: - if self.blink: - button.get_style_context().add_class("blink") - else: - button.get_style_context().remove_class("blink") - self.blink = not self.blink - return True diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/buttons.py voctomix-1.3+git20200102/voctogui/lib/toolbar/buttons.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/buttons.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/buttons.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -from gi.repository import Gtk -import sys -from lib.toolbar.widgets import _decode, Widgets - -class Buttons(Widgets): - ''' reads toolbar buttons from configuration and adds them into a toolbar - items from INI file can shall look like this: - - additional some attributes will be added automatically: - - 'button' is the created button instance - ''' - - def __init__(self, cfg_items): - super().__init__(cfg_items,"buttons") - - def create(self, toolbar, accelerators=None, callback=None, css=[], group=True, radio=True, sensitive=True, visible=True, multiline_names=True): - ''' create toolbar from read configuration items ''' - - # generate a list of all buttons - buttons = [] - first_btn = None - for id, attr in self.items(): - if radio: - # create button and manage grouping of radio buttons - if group: - if not first_btn: - first_btn = btn = Gtk.RadioToolButton(None) - else: - btn = Gtk.RadioToolButton.new_from_widget(first_btn) - else: - btn = Gtk.ToggleToolButton() - else: - btn = Gtk.ToolButton() - - # set button properties - self.add(btn, id, accelerators, callback, ('toggled' if radio else 'clicked'), css, sensitive, visible, multiline_names) - btn.set_visible_horizontal(visible) - btn.set_visible_vertical(visible) - btn.set_can_focus(False) - - # remember created button in attributes - attr['button'] = btn - - # store button - buttons.append( - (int(attr['pos']) if 'pos' in attr else sys.maxsize, btn)) - - # add all buttons in right order - def key(x): - return x[0] - pos = toolbar.get_n_items() - for btn in sorted(buttons, key=key): - toolbar.insert(btn[1], pos) - pos += 1 diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/composition.py voctomix-1.3+git20200102/voctogui/lib/toolbar/composition.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/composition.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/composition.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,118 @@ +import logging + +from gi.repository import Gtk +import lib.connection as Connection + +from lib.config import Config + + +class CompositionToolbarController(object): + """Manages Accelerators and Clicks on the Composition Toolbar-Buttons""" + + def __init__(self, toolbar, win, uibuilder): + self.log = logging.getLogger('CompositionToolbarController') + + accelerators = Gtk.AccelGroup() + win.add_accel_group(accelerators) + + composites = [ + 'picture_in_picture', + 'side_by_side_equal', + 'side_by_side_preview' + ] + + sources = Config.getlist('mix', 'sources') + + self.composite_btns = {} + self.current_composition = None + + fullscreen_btn = uibuilder.find_widget_recursive( + toolbar, 'composite-fullscreen') + + fullscreen_btn_pos = toolbar.get_item_index(fullscreen_btn) + + accel_f_key = 1 + + for idx, name in enumerate(sources): + key, mod = Gtk.accelerator_parse('F%u' % accel_f_key) + + if idx == 0: + new_btn = fullscreen_btn + else: + new_icon = Gtk.Image.new_from_pixbuf( + fullscreen_btn.get_icon_widget().get_pixbuf()) + new_btn = Gtk.RadioToolButton(group=fullscreen_btn) + new_btn.set_icon_widget(new_icon) + toolbar.insert(new_btn, fullscreen_btn_pos + idx) + + new_btn.set_label("Fullscreen %s\nF%s" % (name, accel_f_key)) + new_btn.connect('toggled', self.on_btn_toggled) + new_btn.set_name('fullscreen %s' % name) + + tooltip = Gtk.accelerator_get_label(key, mod) + new_btn.set_tooltip_text(tooltip) + + new_btn.get_child().add_accelerator( + 'clicked', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + + self.composite_btns['fullscreen %s' % name] = new_btn + accel_f_key = accel_f_key + 1 + + for idx, name in enumerate(composites): + key, mod = Gtk.accelerator_parse('F%u' % accel_f_key) + + btn = uibuilder.find_widget_recursive( + toolbar, + 'composite-' + name.replace('_', '-') + ) + btn.set_name(name) + + btn.set_label(btn.get_label() + "\nF%s" % accel_f_key) + + tooltip = Gtk.accelerator_get_label(key, mod) + btn.set_tooltip_text(tooltip) + + # Thanks to http://stackoverflow.com/a/19739855/1659732 + btn.get_child().add_accelerator('clicked', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + btn.connect('toggled', self.on_btn_toggled) + + self.composite_btns[name] = btn + accel_f_key = accel_f_key + 1 + + # connect event-handler and request initial state + Connection.on('composite_mode_and_video_status', + self.on_composite_mode_and_video_status) + + Connection.send('get_composite_mode_and_video_status') + + def on_btn_toggled(self, btn): + if not btn.get_active(): + return + + btn_name = btn.get_name() + self.log.info('btn_name = %s', btn_name) + if self.current_composition == btn_name: + self.log.info('composition-mode already active: %s', btn_name) + return + + self.log.info('composition-mode activated: %s', btn_name) + + if btn_name.startswith('fullscreen'): + _, source_name = btn_name.split(' ', 1) + Connection.send('set_videos_and_composite', + source_name, '*', 'fullscreen') + + else: + Connection.send('set_composite_mode', btn_name) + + def on_composite_mode_and_video_status(self, mode, source_a, source_b): + self.log.info('composite_mode_and_video_status callback w/ ' + 'mode: %s, source a: %s, source b: %s', + mode, source_a, source_b) + if mode == 'fullscreen': + mode = 'fullscreen %s' % source_a + + self.current_composition = mode + self.composite_btns[mode].set_active(True) diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/helpers.py voctomix-1.3+git20200102/voctogui/lib/toolbar/helpers.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/helpers.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -import os - -def mark_label(btn): - label = btn.get_label() - label = label.replace('▶ ','').replace(' ◀','') - label = "▶ " + label + " ◀" - btn.set_label(label) - -def unmark_label(btn): - label = btn.get_label() - label = label.replace('▶ ','').replace(' ◀','') - btn.set_label(label) - -def top_dir_path(): - return os.path.dirname(os.path.abspath(__file__ + '/../..')) - - -def icon_path(): - return os.path.join(top_dir_path(), 'ui') diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/misc.py voctomix-1.3+git20200102/voctogui/lib/toolbar/misc.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/misc.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/misc.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,6 +1,5 @@ -#!/usr/bin/env python3 import logging -from gi.repository import Gdk, Gtk +from gi.repository import Gtk from lib.config import Config import lib.connection as Connection @@ -9,79 +8,31 @@ class MiscToolbarController(object): """Manages Accelerators and Clicks Misc buttons""" - def __init__(self, win, uibuilder, queues_controller, ports_controller, video_display): - self.win = win + def __init__(self, toolbar, win, uibuilder): self.log = logging.getLogger('MiscToolbarController') - self.toolbar = uibuilder.find_widget_recursive(win, 'toolbar_main') # Accelerators accelerators = Gtk.AccelGroup() win.add_accel_group(accelerators) - closebtn = uibuilder.find_widget_recursive(self.toolbar, 'close') - closebtn.set_visible(Config.getShowCloseButton()) + closebtn = uibuilder.find_widget_recursive(toolbar, 'close') + closebtn.set_visible(Config.getboolean('misc', 'close')) closebtn.connect('clicked', self.on_closebtn_clicked) - fullscreenbtn = uibuilder.find_widget_recursive(self.toolbar, 'fullscreen') - fullscreenbtn.set_visible(Config.getShowFullScreenButton()) - fullscreenbtn.connect('clicked', self.on_fullscreenbtn_clicked) - key, mod = Gtk.accelerator_parse('F11') - fullscreenbtn.add_accelerator('clicked', accelerators, - key, mod, Gtk.AccelFlags.VISIBLE) - self.fullscreen_button = fullscreenbtn - - mutebtn = uibuilder.find_widget_recursive(self.toolbar, 'mute_button') - if Config.getPlayAudio(): - mutebtn.set_active(True) - mutebtn.connect('clicked', self.on_mutebtn_clicked) - self.video_display = video_display - else: - mutebtn.set_no_show_all(True) - mutebtn.hide() - - queues_button = uibuilder.find_widget_recursive(self.toolbar, 'queue_button') - queues_button.set_visible(Config.getShowQueueButton()) - queues_button.connect('toggled', self.on_queues_button_toggled) - self.queues_controller = queues_controller - - ports_button = uibuilder.find_widget_recursive(self.toolbar, 'ports_button') - ports_button.set_visible(Config.getShowPortButton()) - ports_button.connect('toggled', self.on_ports_button_toggled) - self.ports_controller = ports_controller + cutbtn = uibuilder.find_widget_recursive(toolbar, 'cut') + cutbtn.set_visible(Config.getboolean('misc', 'cut')) + cutbtn.connect('clicked', self.on_cutbtn_clicked) key, mod = Gtk.accelerator_parse('t') + cutbtn.add_accelerator('clicked', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) tooltip = Gtk.accelerator_get_label(key, mod) - - # Controller for fullscreen behavior - self.__is_fullscreen = False - win.connect("window-state-event", self.on_window_state_event) + cutbtn.set_tooltip_text(tooltip) def on_closebtn_clicked(self, btn): self.log.info('close-button clicked') Gtk.main_quit() - def on_fullscreenbtn_clicked(self, btn): - if not self.within_state_event: - self.log.info('fullscreen-button clicked') - if self.__is_fullscreen: - self.win.unfullscreen() - else: - self.win.fullscreen() - - def on_mutebtn_clicked(self, btn): - self.log.info('mute-button clicked') - self.video_display.mute(not btn.get_active()) - - def on_queues_button_toggled(self, btn): - self.log.info('queues-button clicked') - self.queues_controller.show(btn.get_active()) - - def on_ports_button_toggled(self, btn): - self.log.info('queues-button clicked') - self.ports_controller.show(btn.get_active()) - - def on_window_state_event(self, widget, ev): - self.within_state_event = True - self.__is_fullscreen = bool(ev.new_window_state & Gdk.WindowState.FULLSCREEN) - self.fullscreen_button.set_active(self.__is_fullscreen) - self.within_state_event = False + def on_cutbtn_clicked(self, btn): + self.log.info('cut-button clicked') + Connection.send('message', 'cut') diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/mix.py voctomix-1.3+git20200102/voctogui/lib/toolbar/mix.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/mix.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/mix.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -import os -import logging - -from gi.repository import Gtk -import lib.connection as Connection - -from lib.config import Config -from vocto.composite_commands import CompositeCommand -from lib.toolbar.buttons import Buttons -from lib.uibuilder import UiBuilder - - -class MixToolbarController(object): - """Manages Accelerators and Clicks on the Preview Composition Toolbar-Buttons""" - - def __init__(self, win, uibuilder, preview_controller, overlay_controller): - self.initialized = False - self.preview_controller = preview_controller - self.overlay_controller = overlay_controller - self.log = logging.getLogger('PreviewToolbarController') - - accelerators = Gtk.AccelGroup() - win.add_accel_group(accelerators) - - self.mix = Buttons(Config.getToolbarMix()) - - self.toolbar = uibuilder.find_widget_recursive(win, 'toolbar_mix') - - self.mix.create(self.toolbar, accelerators, - self.on_btn_clicked, radio=False) - Connection.on('best', self.on_best) - - def on_btn_clicked(self, btn): - id = btn.get_name() - - # on transition hide overlay if AUTO-OFF is on - if self.overlay_controller.isAutoOff() and id != 'retake': - Connection.send('show_overlay',str(False)) - - command = self.preview_controller.command() - output = self.preview_controller.output - if command.A == output.A and command.B != output.B: - output.B = command.B - if command.B == output.B and command.A != output.A: - output.A = command.A - self.preview_controller.set_command(output,False) - if id == 'cut': - self.log.info('Sending new composite: %s', command) - Connection.send('cut', str(command)) - elif id == 'trans': - self.log.info( - 'Sending new composite (using transition): %s', command) - Connection.send('transition', str(command)) - else: - Connection.send('get_composite') - self.mix['retake']['button'].set_sensitive(self.preview_controller.command() != self.preview_controller.output) - - def on_best(self, best, targetA, targetB): - command = self.preview_controller.command() - self.mix['retake']['button'].set_sensitive(command != self.preview_controller.output) - self.mix['trans']['button'].set_sensitive(best == "transition") - self.mix['cut']['button'].set_sensitive((best == "transition" or best == "cut") and not (command.composite == "lec" or command.composite == "|lec")) diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/overlay.py voctomix-1.3+git20200102/voctogui/lib/toolbar/overlay.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/overlay.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/overlay.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -import os -import logging - -from gi.repository import Gtk -import lib.connection as Connection - -from lib.config import Config -from lib.uibuilder import UiBuilder -from lib.toolbar.widgets import Widgets -from datetime import datetime, timedelta -from vocto.command_helpers import quote, dequote, str2bool - -class OverlayToolbarController(object): - """Manages Accelerators and Clicks on the Overlay Composition Toolbar-Buttons""" - - def __init__(self, win, uibuilder): - self.initialized = False - - self.log = logging.getLogger('OverlayToolbarController') - - accelerators = Gtk.AccelGroup() - win.add_accel_group(accelerators) - - if Config.hasOverlay(): - - widgets = Widgets(Config.getToolbarInsert()) - - # connect to inserts selection combo box - self.inserts = uibuilder.get_check_widget('inserts') - self.inserts_store = uibuilder.get_check_widget('insert-store') - self.inserts.connect('changed', self.on_inserts_changed) - - # connect to INSERT toggle button - self.insert = uibuilder.get_check_widget('insert') - widgets.add(self.insert, 'insert', accelerators, self.on_insert_toggled, signal='toggled' ) - - self.update_inserts = uibuilder.get_check_widget('update-inserts') - widgets.add(self.update_inserts, 'update', accelerators, self.update_overlays) - - # initialize to AUTO-OFF toggle button - self.autooff = uibuilder.get_check_widget('insert-auto-off') - self.autooff.set_visible(Config.getOverlayUserAutoOff()) - self.autooff.set_active(Config.getOverlayAutoOff()) - widgets.add(self.autooff, 'auto-off', accelerators) - - # remember overlay description label - self.overlay_description = uibuilder.get_check_widget( - 'overlay-description') - - # initialize our overlay list until we get one from the core - self.overlays = [] - - # what we receive from core - Connection.on('overlays', self.on_overlays) - Connection.on('overlays_title', self.on_overlays_title) - Connection.on('overlay', self.on_overlay) - Connection.on('overlay_visible', self.on_overlay_visible) - # call core for a list of available overlays - self.update_overlays() - # show insert tool bar - uibuilder.get_check_widget('box_insert').show() - else: - # hide insert tool bar - uibuilder.get_check_widget('box_insert').hide() - - # Hint: self.initialized will be set to True in response to 'get_overlay' - - def on_insert_toggled(self, btn): - # can't select insert, if we got no list already - if not self.initialized: - return - Connection.send('show_overlay', str(self.insert.get_active())) - - def on_inserts_changed(self, combobox): - ''' new insert was selected - ''' - # can't select insert, if we got no list already - if not self.initialized: - return - # check if there is any useful selection - if self.inserts.get_active_iter(): - # get name of the selection - selected_overlay = self.inserts_store[self.inserts.get_active_iter( - )][0] - # tell log about user selection - self.log.info("setting overlay to '%s'", selected_overlay) - # hide overlay if 'AUTO-OFF' is selected - if self.isAutoOff(): - Connection.send('show_overlay', str(False)) - # select overlay on voctocore - Connection.send('set_overlay', quote(str(selected_overlay))) - - def on_overlay_visible(self, visible): - ''' receive overlay visibility - ''' - # set 'insert' button state - self.insert.set_active(str2bool(visible)) - - def on_overlay(self, overlay): - # decode parameter - overlay = dequote(overlay) - overlays = [o for o, t in self.overlays] - # do we know this overlay? - if overlay in overlays: - # select overlay by name - self.inserts.set_active(overlays.index(overlay)) - else: - if self.overlays: - # select first item as default - self.inserts.set_active(0) - # tell log about new overlay - self.log.info("overlay is '%s'", overlay) - # enable 'INSERT' button if there is a selection - self.insert.set_sensitive(not self.inserts.get_active_iter() is None) - - def on_overlays(self, overlays): - # decode parameter - overlays = [dequote(o).split('|') for o in overlays.split(",")] - overlays = [o if len(o) == 2 else (o[0], o[0]) for o in overlays] - # tell log about overlay list - self.log.info("Got list of overlays from server '%s'", overlays) - # clear inserts storage - self.inserts_store.clear() - # save inserts into storage if there are any - if overlays: - for o in overlays: - self.inserts_store.append(o) - # enable selection widget only if available - self.inserts.set_sensitive(len(overlays) > 1 if overlays else False) - # remember overlay list - self.overlays = overlays - # we have a list of overlays - self.initialized = True - # poll voctocore's current overlay selection - Connection.send('get_overlay_visible') - Connection.send('get_overlay') - - def on_overlays_title(self, title): - # decode parameter - title = [dequote(t) for t in title.split(",")] - # title given? - if title: - # show title - if len(title) == 4: - start, end, id, text = title - self.overlay_description.set_text( - "{start} - {end} : #{id} '{text}'".format(start=start.split(" ")[1], - end=end.split(" ")[1], - id=id, - text=text)) - else: - self.overlay_description.set_text(title[0]) - self.overlay_description.show() - else: - # hide title - self.overlay_description.hide() - # tell log about overlay list - self.log.info("Got title of overlays from server '%s'", title) - - def update_overlays(self,btn=None): - Connection.send('get_overlays') - Connection.send('get_overlays_title') - - def isAutoOff(self): - if Config.hasOverlay(): - return self.autooff.get_active() diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/preview.py voctomix-1.3+git20200102/voctogui/lib/toolbar/preview.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/preview.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/preview.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -import os -import logging -import copy - -from gi.repository import Gtk -import lib.connection as Connection - -from lib.config import Config -from vocto.composite_commands import CompositeCommand -from lib.toolbar.buttons import Buttons -from lib.uibuilder import UiBuilder - - -class PreviewToolbarController(object): - """Manages Accelerators and Clicks on the Preview Composition Toolbar-Buttons""" - - def __init__(self, win, uibuilder): - self.initialized = False - - self.log = logging.getLogger('PreviewToolbarController') - - accelerators = Gtk.AccelGroup() - win.add_accel_group(accelerators) - - self.sourcesA = Buttons(Config.getToolbarSourcesA()) - self.sourcesB = Buttons(Config.getToolbarSourcesB()) - self.composites = Buttons(Config.getToolbarComposites()) - self.mods = Buttons(Config.getToolbarMods()) - - toolbar_composite = uibuilder.find_widget_recursive( - win, 'toolbar_preview_composite') - toolbar_a = uibuilder.find_widget_recursive(win, 'toolbar_preview_a') - toolbar_b = uibuilder.find_widget_recursive(win, 'toolbar_preview_b') - toolbar_mod = uibuilder.find_widget_recursive( - win, 'toolbar_preview_mod') - - self.frame_b = uibuilder.find_widget_recursive(win, 'frame_preview_b') - - # hide modify box if not needed - box_modify = uibuilder.find_widget_recursive(win, 'box_preview_modify') - if not Config.getToolbarMods(): - box_modify.hide() - box_modify.set_no_show_all(True) - - self.composites.create(toolbar_composite,accelerators, self.on_btn_toggled) - self.sourcesA.create(toolbar_a, accelerators, self.on_btn_toggled) - self.sourcesB.create(toolbar_b, accelerators, self.on_btn_toggled) - self.mods.create(toolbar_mod, accelerators, self.on_btn_toggled, group=False) - - self.invalid_buttons = [] - self.validate(self.sourcesA) - self.validate(self.sourcesB) - - # initialize source buttons - self.sourceA = Config.getSources()[0] - self.sourceB = Config.getSources()[1] - self.sourcesA[self.sourceA]['button'].set_active(True) - self.sourcesB[self.sourceB]['button'].set_active(True) - - self.composite = self.composites.ids[0] - self.composites[self.composite]['button'].set_active(True) - - self.modstates = dict() - for id in self.mods.ids: - self.modstates[id] = False - - # load composites from config - self.log.info("Reading transitions configuration...") - self.composites_ = Config.getComposites() - - Connection.on('best', self.on_best) - Connection.on('composite', self.on_composite) - Connection.send('get_composite') - self.enable_modifiers() - self.enable_sourcesB() - self.enable_sources(); - - self.do_test = True - self.initialized = True - - def on_btn_toggled(self, btn): - if not self.initialized: - return - - id = btn.get_name() - if btn.get_active(): - # sources button toggled? - if id in self.sourcesA or id in self.sourcesB: - # check for A and B switch to the same source and fix it - if self.sourcesA[id]['button'] == btn: - if id in self.sourcesB and self.sourcesB[id]['button'].get_active(): - self.sourceB = None - self.sourcesB[self.sourceA]['button'].set_active(True) - self.sourceA = id - self.log.info( - "Selected '%s' for preview source A", self.sourceA) - elif self.sourcesB[id]['button'] == btn: - if self.sourcesA[id]['button'].get_active(): - self.sourceA = None - self.sourcesA[self.sourceB]['button'].set_active(True) - self.sourceB = id - self.log.info( - "Selected '%s' for preview source B", self.sourceB) - self.test() - elif id in self.composites: - self.composite = id - self.enable_sourcesB() - self.enable_modifiers() - self.log.info( - "Selected '%s' for preview target composite", self.composite) - self.test() - if id in self.mods: - self.modstates[id] = btn.get_active() - self.log.info("Turned preview modifier '%s' %s", id, - 'on' if self.modstates[id] else 'off') - self.test() - self.enable_sources(); - self.log.debug("current command is '%s", self.command()) - - def enable_modifiers(self): - command = CompositeCommand(self.composite, self.sourceA, self.sourceB) - for id, attr in self.mods.items(): - attr['button'].set_sensitive( command.modify(attr['replace']) ) - - def enable_sourcesB(self): - single = self.composites_[self.composite].single() - self.frame_b.set_sensitive(not single) - - def enable_sources(self): - for invalid_button in self.invalid_buttons: - invalid_button.set_sensitive(False) - - def command(self): - # process all selected replactions - command = CompositeCommand(self.composite, self.sourceA, self.sourceB) - for id, attr in self.mods.items(): - if self.modstates[id]: - command.modify(attr['replace']) - return command - - def test(self): - if self.do_test: - if self.sourceA == self.sourceB: - return False - self.log.info("Testing transition to '%s'", str(self.command())) - Connection.send('best', str(self.command())) - - def set_command(self, command, do_test=True): - self.do_test = do_test - self.log.info("Changing new composite to '%s'", str(self.command())) - if type(command) == str: - command = CompositeCommand.from_str(command) - for id, item in self.mods.items(): - item['button'].set_active( - command.modify(item['replace'], reverse=True)) - self.composites[command.composite]['button'].set_active(True) - self.sourcesA[command.A]['button'].set_active(True) - self.sourcesB[command.B]['button'].set_active(True) - self.test() - self.do_test = True - - def on_best(self, best, targetA, targetB): - c = self.command() - if (c.A, c.B) != (targetA, targetB) and (c.A, c.B) != (targetB, targetA): - c.A = targetA - c.B = targetB - self.do_test = False - self.set_command(c) - self.do_test = True - self.update_glow() - - def on_composite(self, command): - self.output = CompositeCommand.from_str(command) - self.test() - - def update_glow(self): - output = copy.copy(self.output) - for id, item in self.sourcesA.items(): - if id == output.A: - item['button'].get_style_context().add_class("glow") - else: - item['button'].get_style_context().remove_class("glow") - single = self.composites_[self.composite].single() - output_single = self.composites_[output.composite].single() - for id, item in self.sourcesB.items(): - if id == output.B: - if output_single: - item['button'].get_style_context().remove_class("glow") - elif single: - self.sourcesA[id]['button'].get_style_context().add_class("glow") - item['button'].get_style_context().remove_class("glow") - else: - item['button'].get_style_context().add_class("glow") - else: - item['button'].get_style_context().remove_class("glow") - for id, item in self.mods.items(): - if output.unmodify(item['replace']): - item['button'].get_style_context().add_class("glow") - else: - item['button'].get_style_context().remove_class("glow") - for id, item in self.composites.items(): - if id == output.composite: - item['button'].get_style_context().add_class("glow") - else: - item['button'].get_style_context().remove_class("glow") - - def validate(self,sources): - for id, attr in sources.items(): - if id not in Config.getSources(): - self.invalid_buttons.append(attr['button']) diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/streamblank.py voctomix-1.3+git20200102/voctogui/lib/toolbar/streamblank.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/streamblank.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/streamblank.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,113 @@ +import logging + +from gi.repository import Gtk +import lib.connection as Connection + +from lib.config import Config + + +class StreamblankToolbarController(object): + """Manages Accelerators and Clicks on the Composition Toolbar-Buttons""" + + def __init__(self, toolbar, win, uibuilder, warning_overlay): + self.log = logging.getLogger('StreamblankToolbarController') + + self.warning_overlay = warning_overlay + + accelerators = Gtk.AccelGroup() + win.add_accel_group(accelerators) + + livebtn = uibuilder.find_widget_recursive(toolbar, 'stream_live') + blankbtn = uibuilder.find_widget_recursive(toolbar, 'stream_blank') + + blankbtn_pos = toolbar.get_item_index(blankbtn) + + if not Config.getboolean('stream-blanker', 'enabled'): + self.log.info('disabling stream-blanker features ' + 'because the server does not support them: %s', + Config.getboolean('stream-blanker', 'enabled')) + + toolbar.remove(livebtn) + toolbar.remove(blankbtn) + return + + blank_sources = Config.getlist('stream-blanker', 'sources') + + self.current_status = None + + key, mod = Gtk.accelerator_parse('F12') + livebtn.connect('toggled', self.on_btn_toggled) + livebtn.set_name('live') + livebtn.set_label(livebtn.get_label() + "\nF12") + + tooltip = Gtk.accelerator_get_label(key, mod) + livebtn.set_tooltip_text(tooltip) + + livebtn.get_child().add_accelerator('clicked', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + + self.livebtn = livebtn + self.blank_btns = {} + + accel_f_key = 11 + + for idx, name in enumerate(blank_sources): + key, mod = Gtk.accelerator_parse('F%u' % accel_f_key) + + if idx == 0: + new_btn = blankbtn + else: + new_icon = Gtk.Image.new_from_pixbuf(blankbtn.get_icon_widget() + .get_pixbuf()) + new_btn = Gtk.RadioToolButton(group=livebtn) + new_btn.set_icon_widget(new_icon) + toolbar.insert(new_btn, blankbtn_pos) + + new_btn.set_label("Stream %s\nF%s" % (name, accel_f_key)) + new_btn.connect('toggled', self.on_btn_toggled) + new_btn.set_name(name) + + tooltip = Gtk.accelerator_get_label(key, mod) + new_btn.set_tooltip_text(tooltip) + + new_btn.get_child().add_accelerator( + 'clicked', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + + self.blank_btns[name] = new_btn + accel_f_key = accel_f_key - 1 + + # connect event-handler and request initial state + Connection.on('stream_status', self.on_stream_status) + Connection.send('get_stream_status') + + def on_btn_toggled(self, btn): + if not btn.get_active(): + return + + btn_name = btn.get_name() + if btn_name == 'live': + self.warning_overlay.disable() + + else: + self.warning_overlay.enable(btn_name) + + if self.current_status == btn_name: + self.log.info('stream-status already activate: %s', btn_name) + return + + self.log.info('stream-status activated: %s', btn_name) + if btn_name == 'live': + Connection.send('set_stream_live') + else: + Connection.send('set_stream_blank', btn_name) + + def on_stream_status(self, status, source=None): + self.log.info('on_stream_status callback w/ status %s and source %s', + status, source) + + self.current_status = source if source is not None else status + if status == 'live': + self.livebtn.set_active(True) + else: + self.blank_btns[source].set_active(True) diff -Nru voctomix-1.3+git20200101/voctogui/lib/toolbar/widgets.py voctomix-1.3+git20200102/voctogui/lib/toolbar/widgets.py --- voctomix-1.3+git20200101/voctogui/lib/toolbar/widgets.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/toolbar/widgets.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -from gi.repository import Gtk -import sys - -def _decode(text, multiline=True): - ''' decode multiline text ''' - if multiline: - text = text.replace('\\n', '\n') - else: - text = text.replace('\\n', ' ') - return text - - -class Widgets(dict): - ''' reads widget setup from configuration from INI file. - shall look like this: - - myitem.name = My Item - myitem.key = F1 - myitem.tip = Some tooltip text - - 'myitem' will be the ID of the item used to identify the button. - 'name' is an optional attribute which replaces the item ID in the button label - 'tip' is an optional attribute which will be used for a tool tip message - - additional some attributes will be added automatically: - - 'id' is a copy of the ID within the attributes - 'button' is the created button instance - - an extra member 'ids' becomes a list of all available IDs - ''' - - def __init__(self, cfg_items, listname="widgets"): - # read all config items with their attributes - self.ids = [] - if cfg_items: - filter = cfg_items[listname].split( - ',') if listname in cfg_items else None - for cfg_name, cfg_value in cfg_items.items(): - if cfg_name != listname: - id, attr = cfg_name.rsplit('.', 1) - if (filter is None) or id in filter: - if id not in self: - self.ids.append(id) - self[id] = dict() - self[id]['id'] = id - self[id][attr] = cfg_value - - def add(self, widget, id, accelerators=None, callback=None, signal='clicked', css=[], sensitive=True, visible=True, multiline_names=True): - # set button properties - widget.set_can_focus(False) - widget.set_sensitive(sensitive) - widget.set_visible(visible) - - # set button style class - context = widget.get_style_context() - for c in css: - context.add_class(c) - - # set interaction callback - if callback: - widget.connect(signal, callback) - - if id in self: - attr = self[id] - - widget.set_name(id) - - # set button label - if 'name' in attr: - name = _decode(attr['name'], multiline_names).upper() - else: - name = id.upper() - widget.set_label(name) - - # set button tooltip - if 'tip' in attr: - tip = _decode(attr['tip']) - else: - tip = "Select source %s" % _decode(name, False) - - # set accelerator key and tooltip - if accelerators and 'key' in attr: - key, mod = Gtk.accelerator_parse(attr['key']) - widget.set_tooltip_text( - "%s\nKey: '%s'" % (tip, attr['key'].upper())) - # @HACK: found no explanation why ToolItems must attach their - # accelerators to the child window - w = widget.get_child() if isinstance(widget,Gtk.ToolItem) else widget - w.add_accelerator( - 'clicked', accelerators, - key, mod, Gtk.AccelFlags.VISIBLE) - else: - widget.set_tooltip_text(tip) - - # set button tooltip - if 'expand' in attr: - widget.set_expand(True) - - widget.set_can_focus(False) diff -Nru voctomix-1.3+git20200101/voctogui/lib/ui.py voctomix-1.3+git20200102/voctogui/lib/ui.py --- voctomix-1.3+git20200101/voctogui/lib/ui.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/ui.py 2020-01-03 00:02:24.000000000 +0000 @@ -6,20 +6,17 @@ from lib.videodisplay import VideoDisplay from lib.audioleveldisplay import AudioLevelDisplay -from lib.audiodisplay import AudioDisplay +from lib.warningoverlay import VideoWarningOverlay + from lib.videopreviews import VideoPreviewsController -from lib.queues import QueuesWindowController -from lib.ports import PortsWindowController -from lib.toolbar.mix import MixToolbarController -from lib.toolbar.preview import PreviewToolbarController -from lib.toolbar.overlay import OverlayToolbarController -from lib.toolbar.blinder import BlinderToolbarController +from lib.toolbar.composition import CompositionToolbarController +from lib.toolbar.streamblank import StreamblankToolbarController from lib.toolbar.misc import MiscToolbarController -from lib.studioclock import StudioClock +from lib.shortcuts import show_shortcuts -from vocto.port import Port +from lib.studioclock import StudioClock class Ui(UiBuilder): @@ -35,103 +32,84 @@ self.win = self.get_check_widget('window') # check for configuration option mainwindow/force_fullscreen - if Config.getForceFullScreen(): + if Config.getboolean('mainwindow', 'forcefullscreen', fallback=False): self.log.info( 'Forcing main window to full screen by configuration') # set window into fullscreen mode self.win.fullscreen() else: # check for configuration option mainwindow/width and /height - if Config.getWindowSize(): + if Config.has_option('mainwindow', 'width') \ + and Config.has_option('mainwindow', 'height'): + # get size from config + width = Config.getint('mainwindow', 'width') + height = Config.getint('mainwindow', 'height') + self.log.info( + 'Set window size by configuration to %d:%d', width, height) # set window size - self.win.set_size_request(Config.getWindowSize()) + self.win.set_size_request(width, height) self.win.set_resizable(False) # Connect Close-Handler self.win.connect('delete-event', Gtk.main_quit) - output_aspect_ratio = self.find_widget_recursive( - self.win, 'output_aspect_ratio') - output_aspect_ratio.props.ratio = Config.getVideoRatio() - - audio_box = self.find_widget_recursive(self.win, 'audio_box') - - # Setup Preview Controller - self.video_previews = VideoPreviewsController( - self.find_widget_recursive(self.win, 'preview_box'), - audio_box, - win=self.win, - uibuilder=self - ) - if Config.getPreviewsEnabled(): - for idx, source in enumerate(Config.getSources()): - self.video_previews.addPreview(self, source, - Port.SOURCES_PREVIEW + idx) - elif Config.getMirrorsEnabled(): - for idx, source in enumerate(Config.getMirrorsSources()): - self.video_previews.addPreview( - self, source, Port.SOURCES_OUT + idx) - else: - self.log.warning( - 'Can not show source previews because neither previews nor mirrors are enabled (see previews/enabled and mirrors/enabled in core configuration)') - - self.mix_audio_display = AudioDisplay(audio_box, "mix", uibuilder=self) + # Get Audio-Level Display + self.audio_level_display = self.find_widget_recursive( + self.win, 'audiolevel_main') + # Create Main-Video Overlay Controller + drawing_area = self.find_widget_recursive(self.win, + 'video_overlay_drawingarea') + self.video_warning_overlay = VideoWarningOverlay(drawing_area) # Create Main-Video Display - self.mix_video_display = VideoDisplay( - self.find_widget_recursive(self.win, 'video_main'), - self.mix_audio_display, - port=Port.MIX_PREVIEW if Config.getPreviewsEnabled() else Port.MIX_OUT, - name="MIX" + drawing_area = self.find_widget_recursive(self.win, 'video_main') + self.main_video_display = VideoDisplay( + drawing_area, + port=11000, + play_audio=Config.getboolean('mainvideo', 'playaudio'), + level_callback=self.audio_level_display.level_callback ) - for idx, livepreview in enumerate(Config.getLivePreviews()): - if Config.getPreviewsEnabled(): - self.video_previews.addPreview( - self, '{}-live'.format(livepreview), Port.LIVE_PREVIEW + idx, has_volume=False) - else: - self.video_previews.addPreview( - self, '{}-live'.format(livepreview), Port.LIVE_OUT + idx, has_volume=False) - - self.preview_toolbar_controller = PreviewToolbarController( + # Setup Preview Controller + box_left = self.find_widget_recursive(self.win, 'box_left') + self.video_previews_controller = VideoPreviewsController( + box_left, win=self.win, uibuilder=self ) - self.overlay_toolbar_controller = OverlayToolbarController( + # Setup Toolbar Controllers + toolbar = self.find_widget_recursive(self.win, 'toolbar') + self.composition_toolbar_controller = CompositionToolbarController( + toolbar, win=self.win, uibuilder=self ) - self.mix_toolbar_controller = MixToolbarController( + self.streamblank_toolbar_controller = StreamblankToolbarController( + toolbar, win=self.win, uibuilder=self, - preview_controller=self.preview_toolbar_controller, - overlay_controller=self.overlay_toolbar_controller - ) - - self.blinder_toolbar_controller = BlinderToolbarController( - win=self.win, - uibuilder=self + warning_overlay=self.video_warning_overlay ) - self.queues_controller = QueuesWindowController(self) - self.ports_controller = PortsWindowController(self) - self.misc_controller = MiscToolbarController( + toolbar, win=self.win, - uibuilder=self, - queues_controller=self.queues_controller, - ports_controller=self.ports_controller, - video_display=self.mix_video_display + uibuilder=self ) # Setup Shortcuts window + self.win.connect('key-press-event', self.handle_keypress) self.win.connect('window-state-event', self.handle_state) + def handle_keypress(self, window, event): + if event.keyval == Gdk.KEY_question: + show_shortcuts(window) + def handle_state(self, window, event): # force full screen if whished by configuration - if Config.getForceFullScreen(): + if Config.getboolean('mainwindow', 'forcefullscreen', fallback=False): self.log.info('re-forcing fullscreen mode') self.win.fullscreen() diff -Nru voctomix-1.3+git20200101/voctogui/lib/videodisplay.py voctomix-1.3+git20200102/voctogui/lib/videodisplay.py --- voctomix-1.3+git20200101/voctogui/lib/videodisplay.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/videodisplay.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,217 +1,188 @@ import logging -import sys - -from gi.repository import Gst, Gdk +import re +from gi.repository import Gst from lib.args import Args from lib.config import Config from lib.clock import Clock -from vocto.port import Port -from vocto.debug import gst_generate_dot -from vocto.pretty import pretty - class VideoDisplay(object): """Displays a Voctomix-Video-Stream into a GtkWidget""" - def __init__(self, video_drawing_area, audio_display, port, name, width=None, height=None, - play_audio=False): - self.log = logging.getLogger('VideoDisplay:%s' % name) - self.name = name - self.video_drawing_area = video_drawing_area - self.level_callback = audio_display.callback - video_decoder = None + def __init__(self, drawing_area, port, width=None, height=None, + play_audio=False, level_callback=None): + self.log = logging.getLogger('VideoDisplay[%u]' % port) - # Setup Server-Connection, Demuxing and Decoding - pipe = """ - tcpclientsrc - name=tcpsrc-{name} - host={host} - port={port} - blocksize=1048576 - ! matroskademux - name=demux-{name} - """.format(name=name, - host=Config.getHost(), - port=port) + self.drawing_area = drawing_area + self.level_callback = level_callback + + if Config.has_option('previews', 'videocaps'): + previewcaps = Config.get('previews', 'videocaps') + else: + previewcaps = Config.get('mix', 'videocaps') - if Config.getPreviewsEnabled(): + use_previews = Config.getboolean('previews', 'enabled') \ + and Config.getboolean('previews', 'use') + + audiostreams = int(Config.get('mix', 'audiostreams')) + + if (Config.get('mainvideo', 'vumeter') != 'all') \ + and int(Config.get('mainvideo', 'vumeter')) < audiostreams: + audiostreams = int(Config.get('mainvideo', 'vumeter')) + + # Preview-Ports are Raw-Ports + 1000 + if use_previews: self.log.info('using encoded previews instead of raw-video') - if Config.getPreviewVaapi(): - if Gst.version() < (1, 8): - vaapi_decoders = { - 'h264': 'vaapidecode_h264', - 'mpeg2': 'vaapidecode_mpeg2', - } - else: - vaapi_decoders = { - 'h264': 'vaapih264dec', - 'mpeg2': 'vaapimpeg2dec', - } + port += 1000 - video_decoder = vaapi_decoders[Config.getPreviewDecoder()] - else: - cpu_decoders = { - 'h264': 'video/x-h264\n! avdec_h264', - 'jpeg': 'image/jpeg\n! jpegdec', - 'mpeg2': 'video/mpeg\nmpegversion=2\n! mpeg2dec' - } - - video_decoder = cpu_decoders[Config.getPreviewDecoder()] - - pipe += """ - demux-{name}. - ! queue - name=queue-video-{name} - ! {video_decoder} - """.format(name=name, - video_decoder=video_decoder) + vdec = 'image/jpeg ! jpegdec' + if Config.has_option('previews', 'vaapi'): + try: + decoder = Config.get('previews', 'vaapi') + decoders = { + 'h264': 'video/x-h264 ! avdec_h264', + 'jpeg': 'image/jpeg ! jpegdec', + 'mpeg2': 'video/mpeg,mpegversion=2 ! mpeg2dec' + } + vdec = decoders[decoder] + except Exception as e: + self.log.error(e) else: - video_decoder = None - preview_caps = 'video/x-raw' self.log.info('using raw-video instead of encoded-previews') - pipe += """ - demux-{name}. - ! queue - name=queue-video-{name} - ! {previewcaps} - """.format(name=name, - previewcaps=preview_caps, - vcaps=Config.getVideoCaps()) + vdec = None - pipe += """ ! videoconvert - ! videoscale - """ + # Setup Server-Connection, Demuxing and Decoding + pipeline = """ + tcpclientsrc host={host} port={port} blocksize=1048576 ! + queue ! + matroskademux name=demux + """ + + if use_previews: + pipeline += """ + demux. ! + {vdec} ! + {previewcaps} ! + queue ! + """ - if Config.getPreviewNameOverlay() and name: - pipe += """\ - ! textoverlay - name=title-{name} - text=\"{name}\" - valignment=bottom - halignment=center - shaded-background=yes - font-desc="Roboto, 22" - """.format(name=name) + else: + pipeline += """ + demux. ! + {vcaps} ! + queue ! + """ # Video Display - videosystem = Config.getVideoSystem() + videosystem = Config.get('videodisplay', 'system') self.log.debug('Configuring for Video-System %s', videosystem) - if videosystem == 'gl': - pipe += """ ! glupload - ! glcolorconvert - ! glimagesinkelement - name=imagesink-{name} - """.format(name=name) + pipeline += """ + glupload ! + glcolorconvert ! + glimagesinkelement + """ elif videosystem == 'xv': - pipe += """ ! xvimagesink - name=imagesink-{name} - """.format(name=name) + pipeline += """ + xvimagesink + """ elif videosystem == 'x': - pipe += """ ! ximagesink - name=imagesink-{name} - """.format(name=name) - - elif videosystem == 'vaapi': - pipe += """ ! vaapisink - name=imagesink-{name} - """.format(name=name) + prescale_caps = 'video/x-raw' + if width and height: + prescale_caps += ',width=%u,height=%u' % (width, height) + + pipeline += """ + videoconvert ! + videoscale ! + {prescale_caps} ! + ximagesink + """.format(prescale_caps=prescale_caps) else: raise Exception( 'Invalid Videodisplay-System configured: %s' % videosystem ) + # If an Audio-Path is required, # add an Audio-Path through a level-Element - pipe += """ - demux-{name}. - ! queue - name=queue-audio-{name} - ! level - name=lvl - interval=50000000 - ! audioconvert - """ + if self.level_callback or play_audio: + for audiostream in range(0, audiostreams): + pipeline += """ + demux.audio_{audiostream} ! + """.format(audiostream=audiostream) + pipeline += """ + {acaps} ! + queue ! + """ + pipeline += """ + level name=lvl_{audiostream} interval=50000000 ! + """.format(audiostream=audiostream) + + # If Playback is requested, push fo pulseaudio + if play_audio and audiostream == 0: + pipeline += """ + pulsesink + """ - # If Playback is requested, push fo pulseaudio - if play_audio: - pipe += """ ! pulsesink - name=audiosink-{name} - """ - else: - pipe += """ ! fakesink - """ - pipe = pipe.format(name=name, - acaps=Config.getAudioCaps(), - port=port, - ) - - self.log.info("Creating Display-Pipeline:\n%s", pretty(pipe)) - try: - # launch gstreamer pipeline - self.pipeline = Gst.parse_launch(pipe) - self.log.info("pipeline launched successfuly") - except: - self.log.error("Can not launch pipeline") - sys.exit(-1) - - if Args.dot: - self.log.debug("Generating DOT image of videodisplay pipeline") - gst_generate_dot(self.pipeline, "gui.videodisplay.{}".format(name)) + # Otherwise just trash the Audio + else: + pipeline += """ + fakesink + """ + + pipeline = pipeline.format( + acaps=Config.get('mix', 'audiocaps'), + vcaps=Config.get('mix', 'videocaps'), + previewcaps=previewcaps, + host=Args.host if Args.host else Config.get('server', 'host'), + vdec=vdec, + port=port, + ) + self.log.debug('Creating Display-Pipeline:\n%s', pipeline) + self.pipeline = Gst.parse_launch(pipeline) self.pipeline.use_clock(Clock) - self.video_drawing_area.add_events( - Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK) - self.video_drawing_area.connect("realize", self.on_realize) + self.drawing_area.realize() + self.xid = self.drawing_area.get_property('window').get_xid() + self.log.debug('Realized Drawing-Area with xid %u', self.xid) + bus = self.pipeline.get_bus() bus.add_signal_watch() bus.enable_sync_message_emission() bus.connect('message::error', self.on_error) - bus.connect('sync-message::element', self.on_syncmsg) - bus.connect('message::state-changed', self.on_state_changed) - bus.connect("message::element", self.on_level) - - def on_realize(self, win): - self.imagesink = self.pipeline.get_by_name( - 'imagesink-{name}'.format(name=self.name)) - self.xid = self.video_drawing_area.get_property('window').get_xid() + bus.connect("sync-message::element", self.on_syncmsg) - self.log.debug('Realized Drawing-Area with xid %u', self.xid) - self.video_drawing_area.realize() + if self.level_callback: + bus.connect("message::element", self.on_level) - self.log.info("Launching Display-Pipeline") + self.log.debug('Launching Display-Pipeline') self.pipeline.set_state(Gst.State.PLAYING) def on_syncmsg(self, bus, msg): - if type(msg) == Gst.Message and self.imagesink: - if msg.get_structure().get_name() == "prepare-window-handle": - self.log.info( - 'Setting imagesink window-handle to 0x%x', self.xid) - self.imagesink.set_window_handle(self.xid) + if msg.get_structure().get_name() == "prepare-window-handle": + self.log.info('Setting imagesink window-handle to %s', self.xid) + msg.src.set_window_handle(self.xid) def on_error(self, bus, message): + self.log.debug('Received Error-Signal on Display-Pipeline') (error, debug) = message.parse_error() - self.log.error( - "GStreamer pipeline element '%s' signaled an error #%u: %s" % (message.src.name, error.code, error.message)) - - def mute(self, mute): - self.pipeline.get_by_name("audiosink-{name}".format(name=self.name)).set_property( - "volume", 1 if mute else 0) + self.log.debug('Error-Details: #%u: %s', error.code, debug) def on_level(self, bus, msg): - if self.level_callback and msg.src.name == 'lvl': - rms = msg.get_structure().get_value('rms') - peak = msg.get_structure().get_value('peak') - decay = msg.get_structure().get_value('decay') - self.level_callback(rms, peak, decay) - - def on_state_changed(self, bus, message): - if message.parse_state_changed().newstate == Gst.State.PLAYING: - self.video_drawing_area.show() + if not(msg.src.name.startswith('lvl_')): + return + + if msg.type != Gst.MessageType.ELEMENT: + return + + rms = msg.get_structure().get_value('rms') + peak = msg.get_structure().get_value('peak') + decay = msg.get_structure().get_value('decay') + stream = int(msg.src.name[4]) + self.level_callback(rms, peak, decay, stream) diff -Nru voctomix-1.3+git20200101/voctogui/lib/videopreviews.py voctomix-1.3+git20200102/voctogui/lib/videopreviews.py --- voctomix-1.3+git20200101/voctogui/lib/videopreviews.py 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/videopreviews.py 2020-01-03 00:02:24.000000000 +0000 @@ -1,68 +1,188 @@ -#!/usr/bin/env python3 import logging import json import math import os from configparser import NoOptionError -from gi.repository import Gtk, Gdk, GObject +from gi.repository import Gtk, GObject from lib.videodisplay import VideoDisplay -from lib.audiodisplay import AudioDisplay import lib.connection as Connection from lib.config import Config -from vocto.port import Port class VideoPreviewsController(object): """Displays Video-Previews and selection Buttons for them""" - def __init__(self, video_box, audio_box, win, uibuilder): + def __init__(self, preview_box, win, uibuilder): self.log = logging.getLogger('VideoPreviewsController') + self.preview_box = preview_box self.win = win + self.sources = Config.getlist('mix', 'sources') self.preview_players = {} self.previews = {} + self.a_btns = {} + self.b_btns = {} self.volume_sliders = {} - self.video_box = video_box - self.audio_box = audio_box + + self.current_source = {'a': None, 'b': None} + + try: + width = Config.getint('previews', 'width') + self.log.debug('Preview-Width configured to %u', width) + except NoOptionError: + width = 320 + self.log.debug('Preview-Width selected as %u', width) + + try: + height = Config.getint('previews', 'height') + self.log.debug('Preview-Height configured to %u', height) + except NoOptionError: + height = int(width * 9 / 16) + self.log.debug('Preview-Height calculated to %u', height) # Accelerators accelerators = Gtk.AccelGroup() win.add_accel_group(accelerators) - # count number of previews - num_previews = len(Config.getSources()) + len(Config.getLivePreviews()) - - # get preview size - self.previewSize = Config.getPreviewSize() + group_a = None + group_b = None - # recalculate preview size if in sum they are too large for screen - screen = Gdk.Screen.get_default() - if screen.get_height() < self.previewSize[1] * num_previews: - height = screen.get_height() / num_previews - self.previewSize = (Config.getVideoRatio() * height, height) - self.log.warning( - 'Resizing previews so that they fit onto screen to WxH={}x{}'.format(*self.previewSize)) + # Check if there is a fixed audio source configured. + # If so, we will remove the volume sliders entirely + # instead of setting them up. + volume_control = \ + Config.getboolean('audio', 'volumecontrol', fallback=True) or \ + Config.getboolean('audio', 'forcevolumecontrol', fallback=False) + + for idx, source in enumerate(self.sources): + self.log.info('Initializing Video Preview %s', source) + + preview = uibuilder.load_check_widget( + 'widget_preview', + os.path.dirname(uibuilder.uifile) + "/widgetpreview.ui") + video = uibuilder.find_widget_recursive(preview, 'video') + + video.set_size_request(width, height) + preview_box.pack_start(preview, fill=False, + expand=False, padding=0) + + player = VideoDisplay(video, port=13000 + idx, + width=width, height=height) + + uibuilder.find_widget_recursive(preview, 'label').set_label(source) + btn_a = uibuilder.find_widget_recursive(preview, 'btn_a') + btn_b = uibuilder.find_widget_recursive(preview, 'btn_b') + + btn_a.set_name("%c %u" % ('a', idx)) + btn_b.set_name("%c %u" % ('b', idx)) + + if not group_a: + group_a = btn_a + else: + btn_a.join_group(group_a) + + if not group_b: + group_b = btn_b + else: + btn_b.join_group(group_b) + + btn_a.connect('toggled', self.btn_toggled) + btn_b.connect('toggled', self.btn_toggled) + + key, mod = Gtk.accelerator_parse('%u' % (idx + 1)) + btn_a.add_accelerator('activate', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + tooltip = Gtk.accelerator_get_label(key, mod) + btn_a.set_tooltip_text(tooltip) + + key, mod = Gtk.accelerator_parse('%u' % (idx + 1)) + btn_b.add_accelerator('activate', accelerators, + key, mod, Gtk.AccelFlags.VISIBLE) + tooltip = Gtk.accelerator_get_label(key, mod) + btn_b.set_tooltip_text(tooltip) + + volume_slider = uibuilder.find_widget_recursive(preview, + 'audio_level') + + if not volume_control: + box = uibuilder.find_widget_recursive(preview, 'box') + box.remove(volume_slider) + else: + volume_slider.set_name("volume {}".format(source)) + volume_signal = volume_slider.connect('value-changed', + self.slider_changed) + + def slider_format(scale, value): + if value == -20.0: + return "-\u221e\u202fdB" + else: + return "{:.{}f}\u202fdB".format(value, + scale.get_digits()) + + volume_slider.connect('format-value', slider_format) + self.volume_sliders[source] = (volume_slider, volume_signal) + + self.preview_players[source] = player + self.previews[source] = preview + self.a_btns[source] = btn_a + self.b_btns[source] = btn_b # connect event-handler and request initial state + Connection.on('video_status', self.on_video_status) Connection.send('get_video') - def addPreview(self, uibuilder, source, port, has_volume=True): - - self.log.info('Initializing video preview %s at port %d', source, port) - - video = uibuilder.load_check_widget('video', - os.path.dirname(uibuilder.uifile) + - "/widgetpreview.ui") - video.set_size_request(*self.previewSize) - self.video_box.pack_start(video, fill=False, - expand=False, padding=0) - - mix_audio_display = AudioDisplay(self.audio_box, source, uibuilder, has_volume) - player = VideoDisplay(video, mix_audio_display, port=port, - width=self.previewSize[0], - height=self.previewSize[1], - name=source.upper() - ) + if volume_control: + Connection.on('audio_status', self.on_audio_status) + Connection.send('get_audio') + + def btn_toggled(self, btn): + if not btn.get_active(): + return + + btn_name = btn.get_name() + self.log.debug('btn_toggled: %s', btn_name) + + channel, idx = btn_name.split(' ')[:2] + source_name = self.sources[int(idx)] + + if self.current_source[channel] == source_name: + self.log.info('video-channel %s already on %s', + channel, source_name) + return + + self.log.info('video-channel %s changed to %s', channel, source_name) + Connection.send('set_video_' + channel, source_name) + + def slider_changed(self, slider): + slider_name = slider.get_name() + source = slider_name.split(' ')[1] + value = slider.get_value() + volume = 10 ** (value / 20) if value > -20.0 else 0 + self.log.debug("slider_changed: {}: {:.4f}".format(source, volume)) + Connection.send('set_audio_volume {} {:.4f}'.format(source, volume)) + + def on_video_status(self, source_a, source_b): + self.log.info('on_video_status callback w/ sources: %s and %s', + source_a, source_b) + + self.current_source['a'] = source_a + self.current_source['b'] = source_b + + self.a_btns[source_a].set_active(True) + self.b_btns[source_b].set_active(True) + + def on_audio_status(self, *volumes): + volumes_json = "".join(volumes) + volumes = json.loads(volumes_json) + + for source, volume in volumes.items(): + volume = 20.0 * math.log10(volume) if volume > 0 else -20.0 + slider, signal = self.volume_sliders[source] + # Temporarily block the 'value-changed' signal, + # so we don't (re)trigger it when receiving (our) changes + GObject.signal_handler_block(slider, signal) + slider.set_value(volume) + GObject.signal_handler_unblock(slider, signal) diff -Nru voctomix-1.3+git20200101/voctogui/lib/warningoverlay.py voctomix-1.3+git20200102/voctogui/lib/warningoverlay.py --- voctomix-1.3+git20200101/voctogui/lib/warningoverlay.py 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/lib/warningoverlay.py 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,61 @@ +import logging +from gi.repository import GLib + + +class VideoWarningOverlay(object): + """Displays a Warning-Overlay above the Video-Feed + of another VideoDisplay""" + + def __init__(self, drawing_area): + self.log = logging.getLogger('VideoWarningOverlay') + + self.drawing_area = drawing_area + self.drawing_area.connect("draw", self.draw_callback) + + self.text = None + self.blink_state = False + + GLib.timeout_add_seconds(1, self.on_blink_callback) + + def on_blink_callback(self): + self.blink_state = not self.blink_state + self.drawing_area.queue_draw() + return True + + def enable(self, text=None): + self.text = text + self.drawing_area.show() + self.drawing_area.queue_draw() + + def set_text(self, text=None): + self.text = text + self.drawing_area.queue_draw() + + def disable(self): + self.drawing_area.hide() + self.drawing_area.queue_draw() + + def draw_callback(self, drawing_area, cr): + w = drawing_area.get_allocated_width() + h = drawing_area.get_allocated_height() + + if self.blink_state: + cr.set_source_rgba(1.0, 0.0, 0.0, 0.8) + else: + cr.set_source_rgba(1.0, 0.5, 0.0, 0.8) + + cr.rectangle(0, 0, w, h) + cr.fill() + + text = "Stream is Blanked" + if self.text: + text += ": " + self.text + + cr.set_font_size(h * 0.75) + (xbearing, ybearing, + txtwidth, txtheight, + xadvance, yadvance) = cr.text_extents(text) + + cr.move_to(w / 2 - txtwidth / 2, h * 0.75) + cr.set_source_rgba(1.0, 1.0, 1.0, 1.0) + cr.show_text(text) diff -Nru voctomix-1.3+git20200101/voctogui/README.md voctomix-1.3+git20200102/voctogui/README.md --- voctomix-1.3+git20200101/voctogui/README.md 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/README.md 2020-01-03 00:02:24.000000000 +0000 @@ -1,294 +1,48 @@ -# 1. VOC2GUI +# Voctogui - The GUI frontend for Voctocore -The GUI new front-end for **VOC2CORE**. +![Screenshot of voctogui in action](voctomix.png) -## 1.1. Contents +## Keyboard Shortcuts +### Composition Modes +- `F1` Fullscreen +- `F2` Picture in Picture +- `F3` Side-by-Side Equal +- `F4` Side-by-Side Preview + +### Select A-Source +- `1` Source Nr. 1 +- `2` Source Nr. 2 +- … + +### Select B-Source +- `Ctrl+1` Source Nr. 1 +- `Ctrl+2` Source Nr. 2 +- … + +### Set Fullscteen +- `Alt+1` Source Nr. 1 +- `Alt+2` Source Nr. 2 +- … + +### Stream Blanking +- F11 Set stream to Pause-Loop +- F12 Set stream Live + +### Other options +- `t` Cut + +### Select an Audio-Source +Click twice on the selection combobox, then select your source within 5 Seconds. (It will auto-lock again after 5 seconds.) + +## Configuration +On startup the GUI reads the following configuration files: + - `/default-config.ini` + - `/config.ini` + - `/etc/voctomix/voctogui.ini` + - `/etc/voctogui.ini` + - `/.voctogui.ini` + - `` - +From top to bottom the individual settings override previous settings. `default-config.ini` should not be edited, because a missing setting will result in a Python exception. -- [1.1. Contents](#11-contents) -- [1.2. Purpose](#12-purpose) -- [1.3. Installation](#13-installation) -- [1.4. Running](#14-running) - - [1.4.1. Usage](#141-usage) - - [1.4.2. Options](#142-options) -- [1.5. Configuration](#15-configuration) - - [1.5.1. Connecting to Host](#151-connecting-to-host) - - [1.5.2. Server Configuration Overwrites Client's One](#152-server-configuration-overwrites-clients-one) - - [1.5.3. Main Window Properties](#153-main-window-properties) - - [1.5.3.1. Window Size](#1531-window-size) - - [1.5.3.2. Force Full Screen](#1532-force-full-screen) - - [1.5.3.3. Main Window Toolbar](#1533-main-window-toolbar) - - [1.5.3.4. Hide Close Button](#1534-hide-close-button) - - [1.5.3.5. Show Full Screen Button](#1535-show-full-screen-button) - - [1.5.3.6. Show Expert Buttons](#1536-show-expert-buttons) - - [1.5.4. Mixing Toolbars](#154-mixing-toolbars) - - [1.5.4.1. Common Widget and Button Configuration](#1541-common-widget-and-button-configuration) - - [1.5.4.2. Sources Toolbars](#1542-sources-toolbars) - - [1.5.4.3. Composites](#1543-composites) - - [1.5.4.4. Modify Toolbar](#1544-modify-toolbar) - - [1.5.4.5. Mix Toolbar](#1545-mix-toolbar) - - [1.5.4.6. Insertions Toolbar](#1546-insertions-toolbar) - - [1.5.5. Customize UI Style](#155-customize-ui-style) -- [1.6. Usage](#16-usage) - - - -## 1.2. Purpose - -The **VOC2GUI** is a GUI client for **VOC2CORE**. -It can connect to a running **VOC2CORE** host and then uses *GTK* to provide a graphical interface to control a lot of the core's functionality. - -![**VOC2GUI** Screenshot](doc/images/voc2gui.png) - -Since the focus of VOC2MIX is to process and control the video mixing of a single A/V recorded talk on stage, **VOC2GUI** is the part where A/V mixing operators can get an easy to use interface to do their job while the event is running. - -This include mixing video, audio, blinding of live output and monitoring the VOC2MIX setup. - -After reading a configuration **VOC2GUI** connect to a host that is doing the bare work of mixing several input video and audio sources into several mixing outputs. -All A/V processing of the core is done by an underlying *GStreamer* instance that processes a so-called pipeline. -To understand what you are able to control with **VOC2GUI** you might first try to understand the principle pipeline construct of **VOC2CORE**. -In this document we presume that you know what your core was configured for. - -## 1.3. Installation - -Todo. - -## 1.4. Running - -### 1.4.1. Usage - -```text -python3 voctogui.py [-h] [-v] [-c {auto,always,never}] [-t] [-i INI_FILE] [-H HOST] [-d] [-D GST_DEBUG_DETAILS] [-g] -``` - -### 1.4.2. Options - -| Option | Alternative | Description -| ------ | ----- | ----- -| `-h` | `--help` | show this help message and exit -| `-v` | `--verbose` | Set verbosity level by using `-v`, `-vv` or `-vvv`. -| `-c` { `auto`, `always`, `never` } | `--color` { `auto`, `always`, `never` } | Control the use of colors in the Log-Output.
Default: `auto` -| `-t` | `--timestamp` | Enable timestamps in the Log-Output -| `-i` *INI_FILE* | `--ini-file` *INI_FILE* | Load a custom configuration file *INI_FILE*.
Default: `./voctogui/default-config.ini` -| `-H` *HOST* | `--host` *HOST* | Connect to this host instead of the configured one. -| `-d` | `--dot` | Generate *DOT* graph files of pipelines into directory set by environment variable `GST_DEBUG_DUMP_DOT_DIR` -| `-D` *GST_DEBUG_DETAILS* | `--gst-debug-details` *GST_DEBUG_DETAILS* | Like `-d` but set detail level in *DOT* graph. *GST_DEBUG_DETAILS* must be a combination the following values:
`1` = show caps-name on edges
`2` = show caps-details on edges
`4` = show modified parameters on elements
`8` = show element states
`16` = show full element parameter values even if they are very long
Default: `15` = show all the typical details that one might want (`15=1+2+4+8`) -| `-g` | `--gstreamer-log` | Log gstreamer messages into voctocore log.
Set log level by using `-g`, `-gg` or `-ggg`). - -## 1.5. Configuration - -By default **VOC2GUI** reads a configuration file from it's repository called `./voctogui/default-config.ini` but it also asks the core for it's configuration. - -### 1.5.1. Connecting to Host - -Do be functional **VOC2GUI** has to connext to the a **VOC2CORE** instance via *TCP/IP*. -Usually this is done by setting the attribtue `server` / `host` within the configuration. - -```ini -[server] -host=localhost -``` - -The `host` entry in the configuration is obligatory unsless you use `-H` to set the host. -The argument overrides the configuration if you use both at the same time. - -### 1.5.2. Server Configuration Overwrites Client's One - -The attributes in the core configuration overwrite the ones defined in the GUI's one so every item (except `server/host`) does not have to be included within the GUI configuration but can be defined by the core configuration to unify all connecting GUI instances. -Instead you could leave things more open to set them in the client configuration individually. - -### 1.5.3. Main Window Properties - -Because **VOC2GUI** is a *GTK* application it needs to open a main window on the screen. -You can influence the way this window appears by the following options: - -```ini -[mainwindow] -width=1920 -height=1080 -forcefullscreen=true -``` - -#### 1.5.3.1. Window Size - -Add pixel sizes to `width` and `height` to set the main window extents when not in full screen mode. - -#### 1.5.3.2. Force Full Screen - -Set `forcefullscreen` if you like **VOC2GUI** to start in full screen mode. - -#### 1.5.3.3. Main Window Toolbar - -The availability of the buttons in the main window toolbar can be customized by the following options: - -```ini -[toolbar] -close=true -fullscreen=true -queues=true -ports=true -``` - -#### 1.5.3.4. Hide Close Button - -Setting `close` to false will hide the close button which is on by default. - -#### 1.5.3.5. Show Full Screen Button - -By setting `fullscreen` to `true` a button wilöl appear which allows the user to toggle full screen mode. -Some window managers might still give the user the ability to toogle full screen mode even if this button is hidden. - -#### 1.5.3.6. Show Expert Buttons - -Setting `queues` and `ports` to `true` will show buttons which toggle the visiblity of the queues and the ports bar. - -**Hint**: If you want to prevent the users to enable those features by changing the GUI configuration file you have to set it to `false` wthin the core configuration! - -### 1.5.4. Mixing Toolbars - -**VOC2GUI** can show several tool bars enveloping different aspects of the mixing process. -In the following paragraphs you learn how to configure them to customize availability of different functionalities, rename widgets, change keyboard accelerators and more. - -#### 1.5.4.1. Common Widget and Button Configuration - -Every mixing toolbar can be configured by using the following attributes. - -| Attribute | Description -| ----------- | -------------------------------------------- -| `.key` | Set the keyboard code that triggers this button.
See about accelerator key codes in GTK. -| `.name` | Customize name of the button on screen. -| `.tip` | Customize the tool top of the button on screen. -| `.expand` | Whether to expand the button within GTK layout. -| `.pos` | Position (0..n) within the toolbar.
If not given append in order they appear in configuration. -| `.replace` | Add composite modificator by character replacement (see chapter *Modifiers* below) - -#### 1.5.4.2. Sources Toolbars - -Without configuring the sources toolbars explicitly **VOC2GUI** lists automatically the available A/B sources as buttons and names them in upper case letters. -Tooltips will be generated too and keyboard accelerators will be set to `F1`..`F4` for sources A and `1`..`4` for sources B. -If you want to vary the visual appearance of the buttons you can use the two sections `toolbar.sources.a` and `toolbar.sources.b`: - -```ini -[toolbar.sources.a] -buttons = cam,grabber - -cam.name = CAMERA -cam.tip = Select camera on A - -grabber.name = LAPTOP -grabber.tip = "Select speaker's laptop on A" - -[toolbar.sources.b] -cam.name = CAMERA -cam.tip = Select camera on B - -grabber.name = LAPTOP -grabber.tip = "Select speaker's laptop on B" -``` - -First you list all sources which shall be shown buttons for by using the `buttons` attribute followed by a comma separated list of the related source's names. -These sources must be defined within the host's configuration. - -Then you might add one or more of the common attributes. - -#### 1.5.4.3. Composites - -Without any configuration composite buttons will be named automatically by their uppercased source names and keyboard accelerators will be `F5`..`F8`. - -If you want to vary the visual appearance of the buttons you can use the two sections `toolbar.composites`: - -```ini -[toolbar.composites] -buttons = fullscreen, sidebyside - -fullscreen.name = FULL SCREEN -fullscreen.tip = Show channel A on full screen - -sidebyside.name = SIDE BY SIDE -sidebyside.tip = Put channel A beside channel B -``` - -These composites must be defined within the host's configuration. - -#### 1.5.4.4. Modify Toolbar - -Without any `.key` attributes keyboard accelerators of the buttons in `toolbar.mods` will be `F9`..`F12`. -Because there are no default modifiers you are free to define some: - -```ini -[toolbar.mods] -buttons = mirror,ratio - -mirror.name = MIRROR -mirror.replace = lecture->|lecture - -ratio.name = 4:3 -ratio.replace = lecture->lecture_43 -``` - -Currently there is only one possibility to add modifiers by using the attribute `replace`: - -##### Replacement Modifier - -Composites are described in **VOCTOMIX** by a specific string format `c(A,B)`, where `c` is the composite and `A` and `B` the sources to show). -For example if you have two sources `cam` and `grabber` and a composite `lecture`. -Assume that `lecture` is a composite which shows a big picture of source A and beside a small one of source B. -You can user modifications for this composite by using *mirroring* or just a fancy way to name your composites. - -*Mirroring* is done by adding a `|` in front of the composites name which then mirrors all coordinates of the composite. -So if `lecture` has the big picture at the left and the other on the right then `|lecture` as the big picture at the right and the other at the left. -Usually this is helpfull if the big picture represents a laptop screen showing a presentation and the small one a camera picture of the speaker. -If the speaker walks from left to right you might use *mirroring* to fix the visual impression, when the speaker is not looking at the presentation. - -Another way is to have two different composites like one for when the laptop screen can have different ratios (like 16:9 and 4:3). -You then can configure an additional lecture composite called `lecture_43` which crops the laptop screen content to 4:3 for example. - -In both cases we can define modifier buttons with `.replace` just by defining a replacement rule for the composite definition string that replaces `lecture` with `|lecture` or `lecture` with `lecture_43` and vice versa like configured in the above sample. - -#### 1.5.4.5. Mix Toolbar - -The mix toolbar can show the following buttons `retake`, `cut`, `trans`. -By adding the attribute `buttons` to the section `toolbar.mix` you might restrict the availability of those. - -Without any `.key` attributes keyboard accelerators of the buttons in `toolbar.mods` will be `BackSpace` for `retake`, `Return` for `cut` and `space` for `trans`. - -```ini -[toolbar.mix] -buttons = cut,trans - -trans.expand = true -``` - -The example will hide the retake button and the transition button will expand in the window layout so that it might be bigger than the cut button. - -#### 1.5.4.6. Insertions Toolbar - -Todo. - -```ini -[toolbar.insert] -auto-off.key = o -auto-off.tip = automatically turn off insertion before every mix - -update.key = u -update.tip = Update current event - -insert.key = i -insert.tip = Show or hide current insertion -``` - -### 1.5.5. Customize UI Style - -Edit `voctogui/ui/voctogui.css` to change the way the user interface will be drawn. -Currently there is no list of class names or IDs but you might start **VOC2GUI** with *GTK* debugging to get these information by setting environment variable `GTK_DEBUG` to `interactive`: - -```bash -GTK_DEBUG=interactive voctogui/voctogui.py -``` - -You will get a window which allows you to browse through all widgets within the **VOC2GUI** window. - -## 1.6. Usage - -Todo. \ No newline at end of file +On startup the GUI fetches all configuration settings from the core and merges them into the GUI config. diff -Nru voctomix-1.3+git20200101/voctogui/ui/audio.ui voctomix-1.3+git20200102/voctogui/ui/audio.ui --- voctomix-1.3+git20200101/voctogui/ui/audio.ui 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/audio.ui 1970-01-01 00:00:00.000000000 +0000 @@ -1,175 +0,0 @@ - - - - - - True - False - headphones.svg - - - True - False - Mute - mute.svg - 3 - - - -20 - 10 - 0.10000000000000001 - - - True - False - 6 - True - 0.5 - in - - - True - False - vertical - - - True - False - 6 - - - True - False - - - True - True - 0 - - - - - 20 - True - False - 0 - 0 - 0 - - - True - True - 1 - - - - - 42 - True - True - vertical - audio_level_adjustment - True - False - 0 - 1 - - - False - True - 2 - - - - - True - False - - - True - True - 3 - - - - - True - True - 0 - - - - - True - False - - - True - False - - - True - True - 0 - - - - - False - False - True - True - Monitor these channels - Monitor - - - False - True - 1 - - - - - True - False - True - - - True - True - 2 - - - - - False - False - True - True - Mute - Mute - - - False - True - 3 - - - - - False - True - 1 - - - - - - - True - False - ORIGINAL - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/blank-stream.svg voctomix-1.3+git20200102/voctogui/ui/blank-stream.svg --- voctomix-1.3+git20200101/voctogui/ui/blank-stream.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/blank-stream.svg 2020-01-03 00:02:24.000000000 +0000 @@ -9,11 +9,11 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="64" - height="48" + width="32" + height="32" id="svg3052" version="1.1" - inkscape:version="0.92.3 (2405546, 2018-03-11)" + inkscape:version="0.48.5 r10040" sodipodi:docname="blank-stream.svg"> @@ -24,21 +24,17 @@ borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="5.544571" - inkscape:cx="-41.27408" - inkscape:cy="-8.7808317" + inkscape:zoom="22.4" + inkscape:cx="9.4155359" + inkscape:cy="15.895534" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" - inkscape:window-width="1912" - inkscape:window-height="1171" + inkscape:window-width="1920" + inkscape:window-height="1014" inkscape:window-x="0" - inkscape:window-y="0" - inkscape:window-maximized="0" - showguides="true" - inkscape:guide-bbox="true" - inkscape:snap-to-guides="true" - inkscape:snap-global="true" /> + inkscape:window-y="27" + inkscape:window-maximized="1" /> @@ -55,80 +51,25 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(0,-1004.3622)"> - - - - - - - - - - - - - - - - - - + transform="translate(0,-1020.3622)"> + + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/clock.svg voctomix-1.3+git20200102/voctogui/ui/clock.svg --- voctomix-1.3+git20200101/voctogui/ui/clock.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/clock.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,162 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/composite-fullscreen.svg voctomix-1.3+git20200102/voctogui/ui/composite-fullscreen.svg --- voctomix-1.3+git20200101/voctogui/ui/composite-fullscreen.svg 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/composite-fullscreen.svg 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,63 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/composite-picture-in-picture.svg voctomix-1.3+git20200102/voctogui/ui/composite-picture-in-picture.svg --- voctomix-1.3+git20200101/voctogui/ui/composite-picture-in-picture.svg 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/composite-picture-in-picture.svg 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,70 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/composite-side-by-side-equal.svg voctomix-1.3+git20200102/voctogui/ui/composite-side-by-side-equal.svg --- voctomix-1.3+git20200101/voctogui/ui/composite-side-by-side-equal.svg 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/composite-side-by-side-equal.svg 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,81 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/composite-side-by-side-preview.svg voctomix-1.3+git20200102/voctogui/ui/composite-side-by-side-preview.svg --- voctomix-1.3+git20200101/voctogui/ui/composite-side-by-side-preview.svg 1970-01-01 00:00:00.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/composite-side-by-side-preview.svg 2020-01-03 00:02:24.000000000 +0000 @@ -0,0 +1,77 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/headphones.svg voctomix-1.3+git20200102/voctogui/ui/headphones.svg --- voctomix-1.3+git20200101/voctogui/ui/headphones.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/headphones.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,81 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/logo.svg voctomix-1.3+git20200102/voctogui/ui/logo.svg --- voctomix-1.3+git20200101/voctogui/ui/logo.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/logo.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,96 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/mute.svg voctomix-1.3+git20200102/voctogui/ui/mute.svg --- voctomix-1.3+git20200101/voctogui/ui/mute.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/mute.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,75 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/nostream.svg voctomix-1.3+git20200102/voctogui/ui/nostream.svg --- voctomix-1.3+git20200101/voctogui/ui/nostream.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/nostream.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,125 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/ports.svg voctomix-1.3+git20200102/voctogui/ui/ports.svg --- voctomix-1.3+git20200101/voctogui/ui/ports.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/ports.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,288 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/queues.svg voctomix-1.3+git20200102/voctogui/ui/queues.svg --- voctomix-1.3+git20200101/voctogui/ui/queues.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/queues.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,65 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/side-by-side-left.svg voctomix-1.3+git20200102/voctogui/ui/side-by-side-left.svg --- voctomix-1.3+git20200101/voctogui/ui/side-by-side-left.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/side-by-side-left.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,162 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/side-by-side-preview-left.svg voctomix-1.3+git20200102/voctogui/ui/side-by-side-preview-left.svg --- voctomix-1.3+git20200101/voctogui/ui/side-by-side-preview-left.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/side-by-side-preview-left.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,165 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/side-by-side-preview.svg voctomix-1.3+git20200102/voctogui/ui/side-by-side-preview.svg --- voctomix-1.3+git20200101/voctogui/ui/side-by-side-preview.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/side-by-side-preview.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,165 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/side-by-side.svg voctomix-1.3+git20200102/voctogui/ui/side-by-side.svg --- voctomix-1.3+git20200101/voctogui/ui/side-by-side.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/side-by-side.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,162 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/slides-in-speaker-left.svg voctomix-1.3+git20200102/voctogui/ui/slides-in-speaker-left.svg --- voctomix-1.3+git20200101/voctogui/ui/slides-in-speaker-left.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/slides-in-speaker-left.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,152 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/slides-in-speaker.svg voctomix-1.3+git20200102/voctogui/ui/slides-in-speaker.svg --- voctomix-1.3+git20200101/voctogui/ui/slides-in-speaker.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/slides-in-speaker.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,152 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/slides.svg voctomix-1.3+git20200102/voctogui/ui/slides.svg --- voctomix-1.3+git20200101/voctogui/ui/slides.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/slides.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,124 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/speaker-in-slides-left.svg voctomix-1.3+git20200102/voctogui/ui/speaker-in-slides-left.svg --- voctomix-1.3+git20200101/voctogui/ui/speaker-in-slides-left.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/speaker-in-slides-left.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,125 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/speaker-in-slides.svg voctomix-1.3+git20200102/voctogui/ui/speaker-in-slides.svg --- voctomix-1.3+git20200101/voctogui/ui/speaker-in-slides.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/speaker-in-slides.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,125 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/speaker.svg voctomix-1.3+git20200102/voctogui/ui/speaker.svg --- voctomix-1.3+git20200101/voctogui/ui/speaker.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/speaker.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,89 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/stream-live.svg voctomix-1.3+git20200102/voctogui/ui/stream-live.svg --- voctomix-1.3+git20200101/voctogui/ui/stream-live.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/stream-live.svg 2020-01-03 00:02:24.000000000 +0000 @@ -9,12 +9,12 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="64" - height="48" + width="32" + height="32" id="svg3052" version="1.1" - inkscape:version="0.91 r13725" - sodipodi:docname="stream-live.svg"> + inkscape:version="0.48.5 r10040" + sodipodi:docname="blank-stream.svg"> + inkscape:window-y="27" + inkscape:window-maximized="1" /> @@ -55,88 +51,13 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(0,-1004.3622)"> - - - - - - - - - - - - - - - - - - - - - - - + transform="translate(0,-1020.3622)"> + + diff -Nru voctomix-1.3+git20200101/voctogui/ui/voc2.svg voctomix-1.3+git20200102/voctogui/ui/voc2.svg --- voctomix-1.3+git20200101/voctogui/ui/voc2.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/voc2.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,66 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/voc.svg voctomix-1.3+git20200102/voctogui/ui/voc.svg --- voctomix-1.3+git20200101/voctogui/ui/voc.svg 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/voc.svg 1970-01-01 00:00:00.000000000 +0000 @@ -1,66 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - diff -Nru voctomix-1.3+git20200101/voctogui/ui/voctogui.css voctomix-1.3+git20200102/voctogui/ui/voctogui.css --- voctomix-1.3+git20200101/voctogui/ui/voctogui.css 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/voctogui.css 2020-01-03 00:02:24.000000000 +0000 @@ -1,152 +1,11 @@ -#mixer { - font-family: roboto condensed; -} - -#mixer .name { - font-size: 1.0em; -} - -#mixer button { - font-size: 20px; - border-radius: 15px; - background-color: #505050; - margin-left:0.3em; - margin-top:0.3em; - margin-bottom:0.3em; - margin-right:0.3em; +.btn_grp_a:checked { background-image: none; + background-color: blue; + color: white; } -#mixer button:disabled { - background-color: #303030; -} - -#mixer label:disabled { - color: #505050; -} - -#mixer button:checked, #mixer button:active { +.btn_grp_b:checked { background-image: none; - background-color: #ddbb00; - color: #000000; -} - -#mixer button:checked:disabled { - background-color: #504530 -} - -#mixer button:checked:disabled label { - color: #302510; -} - -#mixer button:checked label:backdrop { - color: #997700; -} - -#mixer button:checked:disabled label:backdrop { - color: #302510; -} - -#preview_b button:checked:disabled { - background-color: #303030 -} - -#preview_b button:checked:disabled label { - color: #505050; -} - -#preview_b button:checked:disabled label:backdrop { - color: #505050; -} - -#mixer .mode:disabledlabel:backdrop { - color: #106610; -} - -#mixer .mode.blink button:checked { - background-color: #FF0000; - color: #FFFFFF; - border-color: #FF8080; - box-shadow: 0 0 0.3em #FF8080; - text-shadow: 0 0 0.3em #FFFFFF; -} - -#mixer .mode button:checked label:backdrop { - color: #CCAA80; -} - -#mixer .mode.blink button:checked label:backdrop { - color: #FFFFFF; -} - -#mixer .mode button:checked { - background-color: #6B0002; - color: #CCAA80; -} - -#mixer .mode.live button:checked { - background-color: #208020; - color: #80FF80; - border-color: #80FF80; - box-shadow: 0 0 0.3em #80FF80; - text-shadow: 0 0 0.3em #FFFFFF; -} - -#mixer .mode.live button:checked label:backdrop { - color: #80FF80; -} - -#mixer button:checked.option { - background-color: #C06000; - color: #FFDD80; -} - -#mixer button:checked.option label:backdrop { - color: #FFDD80; -} - -#queues { font-size: 1.0em; } - -toolbar { - background-color:rgba(0,0,0,0); -} - -#mixer .glow button { - box-shadow: 0 0 0.3em #ddbb00; - text-shadow: 0 0 0.3em #ddbb00; - color: #ffee80; - border-color: #ddbb00; -} - -#mixer .glow button:disabled { - text-shadow: 0 0 0.3em #403010; -} - -#mixer .glow button:disabled label { - color: #605040; -} - -#mixer .glow button label:backdrop { - color: #ddbb00; -} - -#mixer .glow button:checked label:backdrop { - color: #ffee80; -} - -.notebook { - box-shadow: 0 0 0.3em rgba(0,0,0,128); -} - -#mixer frame border { - background-color:rgba(37,42,44,0); - border-left-color:rgba(74,84,88,0); - border-right-color:rgba(74,84,88,0); - border-top-color:rgb(74,84,88); - border-bottom-color:rgba(74,84,88,0); -} - -#mixer frame > label { - margin-left: 1em; - margin-right: 1em; -} + background-color: red; + color: white; +} \ No newline at end of file diff -Nru voctomix-1.3+git20200101/voctogui/ui/voctogui.ui voctomix-1.3+git20200102/voctogui/ui/voctogui.ui --- voctomix-1.3+git20200101/voctogui/ui/voctogui.ui 2020-01-02 00:02:30.000000000 +0000 +++ voctomix-1.3+git20200102/voctogui/ui/voctogui.ui 2020-01-03 00:02:24.000000000 +0000 @@ -1,93 +1,198 @@ - + - -