Skip to content

Instantly share code, notes, and snippets.

@dasl-
Created November 9, 2021 05:29
Show Gist options
  • Save dasl-/3a6b6abcbd01df2b93668d0e33c190b1 to your computer and use it in GitHub Desktop.
Save dasl-/3a6b6abcbd01df2b93668d0e33c190b1 to your computer and use it in GitHub Desktop.
diff --git a/piwall2/broadcaster/videobroadcaster.py b/piwall2/broadcaster/videobroadcaster.py
index 5ad7495..509d433 100644
--- a/piwall2/broadcaster/videobroadcaster.py
+++ b/piwall2/broadcaster/videobroadcaster.py
@@ -65,91 +65,92 @@ class VideoBroadcaster:
attempt = 1
max_attempts = 2
while attempt <= max_attempts:
try:
self.__broadcast_internal()
break
except YoutubeDlException as e:
if attempt < max_attempts:
self.__logger.warning("Caught exception in VideoBroadcaster.__broadcast_internal: " +
traceback.format_exc())
self.__logger.warning("Updating youtube-dl and retrying broadcast...")
self.__update_youtube_dl()
if attempt >= max_attempts:
raise e
finally:
self.__do_housekeeping()
attempt += 1
def __broadcast_internal(self):
self.__logger.info(f"Starting broadcast for: {self.__video_url}")
+ self.__start_receivers()
+
"""
What's going on here? We invoke youtube-dl (ytdl) three times in the broadcast code:
1) To populate video metadata, including dimensions which allow us to know how much to crop the video
2) To download the proper video format (which generally does not have sound included) and mux it with (3)
3) To download the best audio quality
Ytdl takes couple of seconds to be invoked. Luckily, (2) and (3) happen in parallel
(see self.____get_ffmpeg_input_clause). But that would still leave us with effectively two groups of ytdl
invocations which are happening serially: the group consisting of "1" and the group consisting of "2 and 3".
Note that (1) happens in self.__get_video_info.
By starting a separate process for "2 and 3", we can actually ensure that all three of these invocations
happen in parallel. This separate process is started in self.__start_download_and_convert_video_proc.
This shaves 2-3 seconds off of video start up time -- although this time saving is partially canceled out
by the `time.sleep(2)` we had to add below.
This requires that we break up the original single pipeline into two halves. Originally, a single
pipeline was responsible for downloading, converting, and broadcasting the video. Now we have two
pipelines that we start separately:
1) download_and_convert_video_proc, which downloads and converts the video
2) video_broadcast_proc, which broadcasts the converted video
We connect the stdout of (1) to the stdin of (2).
In order to run all the ytdl invocations in parallel, we had to break up the original single pipeline
into these two halves, because broadcasting the video requires having started the receivers first.
And starting the receivers requires knowing how much to crop, which requires knowing the video dimensions.
Thus, we need to know the video dimensions before broadcasting the video. Without breaking up the pipeline,
we wouldn't be able to enforce that we don't start broadcasting the video before knowing the dimensions.
"""
download_and_convert_video_proc = self.__start_download_and_convert_video_proc()
self.__get_video_info(assert_data_not_yet_loaded = True)
- self.__start_receivers()
"""
This `sleep` makes the videos more likely to start in-sync across all the TVs, but I'm not totally
sure why. My current theory is that this give the receivers enough time to start before the broadcast
command starts sending its data.
Another potential solution is making use of delay_buffer in video_broadcast_cmd, although I have
abandoned that approach for now: https://gist.github.com/dasl-/9ed9d160384a8dd77382ce6a07c43eb6
Another thing I tried was only sending the data once a few megabytes have been read, in case it was a
problem with the first few megabytes of the video being downloaded slowly, but this approach resulted in
occasional very brief video artifacts (green screen, etc) within the first 30 seconds or so of playback:
https://gist.github.com/dasl-/f3fcc941e276d116320d6fa9e4de25de
See data collected on the effectiveness of this sleep:
https://gist.github.com/dasl-/e5c05bf89c7a92d43881a2ff978dc889
"""
- time.sleep(2)
+ # time.sleep(2)
video_broadcast_proc = self.__start_video_broadcast_proc(download_and_convert_video_proc)
self.__logger.info("Waiting for download_and_convert_video and video_broadcast procs to end...")
has_download_and_convert_video_proc_ended = False
has_video_broadcast_proc_ended = False
while True: # Wait for the download_and_convert_video and video_broadcast procs to end...
if not has_download_and_convert_video_proc_ended and download_and_convert_video_proc.poll() is not None:
has_download_and_convert_video_proc_ended = True
if download_and_convert_video_proc.returncode != 0:
raise YoutubeDlException("The download_and_convert_video process exited non-zero: " +
f"{download_and_convert_video_proc.returncode}. This could mean an issue with youtube-dl; " +
"it may require updating.")
self.__logger.info("The download_and_convert_video proc ended.")
if not has_video_broadcast_proc_ended and video_broadcast_proc.poll() is not None:
has_video_broadcast_proc_ended = True
if video_broadcast_proc.returncode != 0:
raise Exception(f"The video broadcast process exited non-zero: {video_broadcast_proc.returncode}")
self.__logger.info("The video_broadcast proc ended.")
@@ -178,70 +179,75 @@ class VideoBroadcaster:
audio_clause = '-c:a mp2 -b:a 192k' # TODO: is this necessary? Can we use mp3?
if self.__get_video_url_type() == self.__VIDEO_URL_TYPE_LOCAL_FILE:
# Don't transcode audio if we don't need to
audio_clause = '-c:a copy'
# Mix the best audio with the video and send via multicast
# See: https://github.com/dasl-/piwall2/blob/main/docs/best_video_container_format_for_streaming.adoc
cmd = (f"set -o pipefail && {self.__get_standard_ffmpeg_cmd()} {ffmpeg_input_clause} " +
f"-c:v copy {audio_clause} -f mpegts -")
self.__logger.info(f"Running download_and_convert_video_proc command: {cmd}")
# Info on start_new_session: https://gist.github.com/dasl-/1379cc91fb8739efa5b9414f35101f5f
# Allows killing all processes (subshells, children, grandchildren, etc as a group)
download_and_convert_video_proc = subprocess.Popen(
cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True, stdout = subprocess.PIPE
)
self.__download_and_convert_video_proc_pgid = os.getpgid(download_and_convert_video_proc.pid)
return download_and_convert_video_proc
def __start_video_broadcast_proc(self, download_and_convert_video_proc):
+ msg = {
+ 'video_width': self.__get_video_info()['width'],
+ 'video_height': self.__get_video_info()['height'],
+ }
+ self.__control_message_helper.send_msg(ControlMessageHelper.TYPE_VIDEO_DIMENSIONS, msg)
+ self.__logger.info("Sent video_dimensions control message.")
+
# See: https://github.com/dasl-/piwall2/blob/main/docs/controlling_video_broadcast_speed.adoc
mbuffer_size = round(Receiver.VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES / 2)
burst_throttling_clause = (f'HOME=/home/pi mbuffer -q -l /tmp/mbuffer.out -m {mbuffer_size}b | ' +
f'{self.__get_standard_ffmpeg_cmd()} -re -i pipe:0 -c:v copy -c:a copy -f mpegts - >/dev/null ; ' +
f'touch {self.__VIDEO_PLAYBACK_DONE_FILE}')
broadcasting_clause = (f"{DirectoryUtils().root_dir}/bin/msend_video " +
f'--log-uuid {shlex.quote(Logger.get_uuid())} ' +
f'--end-of-video-magic-bytes {self.END_OF_VIDEO_MAGIC_BYTES.decode()}')
# Mix the best audio with the video and send via multicast
# See: https://github.com/dasl-/piwall2/blob/main/docs/best_video_container_format_for_streaming.adoc
video_broadcast_cmd = ("set -o pipefail && " +
f"tee >({burst_throttling_clause}) >({broadcasting_clause}) >/dev/null")
self.__logger.info(f"Running broadcast command: {video_broadcast_cmd}")
# Info on start_new_session: https://gist.github.com/dasl-/1379cc91fb8739efa5b9414f35101f5f
# Allows killing all processes (subshells, children, grandchildren, etc as a group)
video_broadcast_proc = subprocess.Popen(
video_broadcast_cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True,
stdin = download_and_convert_video_proc.stdout
)
self.__video_broadcast_proc_pgid = os.getpgid(video_broadcast_proc.pid)
return video_broadcast_proc
def __start_receivers(self):
msg = {
'log_uuid': Logger.get_uuid(),
- 'video_width': self.__get_video_info()['width'],
- 'video_height': self.__get_video_info()['height'],
- 'video_url_type': self.__get_video_url_type(),
+ 'video_url_type': self.__get_video_url_type()
}
self.__control_message_helper.send_msg(ControlMessageHelper.TYPE_PLAY_VIDEO, msg)
self.__logger.info("Sent play_video control message.")
def __get_standard_ffmpeg_cmd(self):
# unfortunately there's no way to make ffmpeg output its stats progress stuff with line breaks
log_opts = '-nostats'
if sys.stderr.isatty():
log_opts = '-stats '
if Logger.get_level() <= Logger.DEBUG:
pass # don't change anything, ffmpeg is pretty verbose by default
else:
log_opts += '-loglevel error'
return f"ffmpeg -hide_banner {log_opts} "
def __get_ffmpeg_input_clause(self):
video_url_type = self.__get_video_url_type()
if video_url_type == self.__VIDEO_URL_TYPE_YOUTUBE:
"""
diff --git a/piwall2/controlmessagehelper.py b/piwall2/controlmessagehelper.py
index 3be55ef..783227e 100644
--- a/piwall2/controlmessagehelper.py
+++ b/piwall2/controlmessagehelper.py
@@ -1,38 +1,39 @@
import json
from piwall2.logger import Logger
from piwall2.multicasthelper import MulticastHelper
# Helper for sending "control messages". Control messages are sent from the broadcaster via
# UDP multicast to control various aspects of the receivers:
# 1) controls volume on the receivers
# 2) signalling for starting video playback
# 3) signalling for skipping a video
# 4) signalling when to apply video effects, like adjusting the video tiling mode
# 5) etc
class ControlMessageHelper:
# Control message types
TYPE_VOLUME = 'volume'
TYPE_PLAY_VIDEO = 'play_video'
TYPE_SKIP_VIDEO = 'skip_video'
TYPE_DISPLAY_MODE = 'display_mode'
+ TYPE_VIDEO_DIMENSIONS = 'video_dimensions'
CTRL_MSG_TYPE_KEY = 'msg_type'
CONTENT_KEY = 'content'
def __init__(self):
self.__logger = Logger().set_namespace(self.__class__.__name__)
def setup_for_broadcaster(self):
self.__multicast_helper = MulticastHelper().setup_broadcaster_socket()
return self
def setup_for_receiver(self):
self.__multicast_helper = MulticastHelper().setup_receiver_control_socket()
return self
def send_msg(self, ctrl_msg_type, content):
msg = json.dumps({
self.CTRL_MSG_TYPE_KEY: ctrl_msg_type,
self.CONTENT_KEY: content
})
diff --git a/piwall2/displaymode.py b/piwall2/displaymode.py
index 3d73c18..02e552d 100644
--- a/piwall2/displaymode.py
+++ b/piwall2/displaymode.py
@@ -1,8 +1,9 @@
class DisplayMode:
# Tile mode is like this: https://i.imgur.com/BBrA1Cr.png
# Repeat mode is like this: https://i.imgur.com/cpS61s8.png
DISPLAY_MODE_TILE = 'DISPLAY_MODE_TILE'
DISPLAY_MODE_REPEAT = 'DISPLAY_MODE_REPEAT'
+
DISPLAY_MODES = (DISPLAY_MODE_TILE, DISPLAY_MODE_REPEAT)
diff --git a/piwall2/receiver/receiver.py b/piwall2/receiver/receiver.py
index 49c6397..7ccc6fc 100644
--- a/piwall2/receiver/receiver.py
+++ b/piwall2/receiver/receiver.py
@@ -65,83 +65,89 @@ class Receiver:
self.__run_internal()
except Exception:
self.__logger.error('Caught exception: {}'.format(traceback.format_exc()))
def __run_internal(self):
ctrl_msg = None
ctrl_msg = self.__control_message_helper.receive_msg() # This blocks until a message is received!
self.__logger.debug(f"Received control message {ctrl_msg}. " +
f"self.__is_video_playback_in_progress: {self.__is_video_playback_in_progress}.")
if self.__is_video_playback_in_progress:
if self.__receive_and_play_video_proc and self.__receive_and_play_video_proc.poll() is not None:
self.__logger.info("Ending video playback because receive_and_play_video_proc is no longer running...")
self.__stop_video_playback_if_playing()
msg_type = ctrl_msg[ControlMessageHelper.CTRL_MSG_TYPE_KEY]
if msg_type == ControlMessageHelper.TYPE_PLAY_VIDEO:
self.__stop_video_playback_if_playing()
self.__receive_and_play_video_proc = self.__receive_and_play_video(ctrl_msg)
self.__receive_and_play_video_proc_pgid = os.getpgid(self.__receive_and_play_video_proc.pid)
+ elif msg_type == ControlMessageHelper.TYPE_VIDEO_DIMENSIONS:
+ if self.__is_video_playback_in_progress:
+ self.__set_video_crop_args(ctrl_msg)
elif msg_type == ControlMessageHelper.TYPE_SKIP_VIDEO:
if self.__is_video_playback_in_progress:
self.__stop_video_playback_if_playing()
elif msg_type == ControlMessageHelper.TYPE_VOLUME:
self.__video_player_volume_pct = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
if self.__is_video_playback_in_progress:
self.__omxplayer_controller.set_vol_pct(self.__video_player_volume_pct)
elif msg_type == ControlMessageHelper.TYPE_DISPLAY_MODE:
display_mode_by_tv_id = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
old_display_mode = self.__display_mode
old_display_mode2 = self.__display_mode2
for tv_num, tv_id in self.__tv_ids.items():
- if tv_id in display_mode_by_tv_id:
display_mode_to_set = display_mode_by_tv_id[tv_id]
if display_mode_to_set not in DisplayMode.DISPLAY_MODES:
display_mode_to_set = DisplayMode.DISPLAY_MODE_TILE
+ if tv_id in display_mode_by_tv_id:
if tv_num == 1:
self.__display_mode = display_mode_to_set
else:
self.__display_mode2 = display_mode_to_set
if self.__is_video_playback_in_progress and self.__crop_args and old_display_mode != self.__display_mode:
self.__omxplayer_controller.set_crop(self.__crop_args[self.__display_mode])
if self.__is_video_playback_in_progress and self.__crop_args2 and old_display_mode2 != self.__display_mode2:
pass # TODO display_mode2 with a second dbus interface name
def __receive_and_play_video(self, ctrl_msg):
ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
self.__orig_log_uuid = Logger.get_uuid()
Logger.set_uuid(ctrl_msg_content['log_uuid'])
- cmd, self.__crop_args, self.__crop_args2 = (
- self.__receiver_command_builder.build_receive_and_play_video_command_and_get_crop_args(
- ctrl_msg_content['log_uuid'], ctrl_msg_content['video_width'],
- ctrl_msg_content['video_height'], self.__video_player_volume_pct,
- self.__display_mode, self.__display_mode2
- )
+ cmd = self.__receiver_command_builder.build_receive_and_play_video_command(
+ ctrl_msg_content['log_uuid'], self.__video_player_volume_pct
)
self.__logger.info(f"Running receive_and_play_video command: {cmd}")
self.__is_video_playback_in_progress = True
proc = subprocess.Popen(
cmd, shell = True, executable = '/usr/bin/bash', start_new_session = True
)
return proc
+ def __set_video_crop_args(self, ctrl_msg):
+ ctrl_msg_content = ctrl_msg[ControlMessageHelper.CONTENT_KEY]
+ self.__crop_args, self.__crop_args2 = self.__receiver_command_builder.get_crop_dimensions(
+ ctrl_msg_content['video_width'], ctrl_msg_content['video_height']
+ )
+ self.__omxplayer_controller.set_crop(self.__crop_args[self.__display_mode])
+
def __stop_video_playback_if_playing(self):
if not self.__is_video_playback_in_progress:
return
if self.__receive_and_play_video_proc_pgid:
self.__logger.info("Killing receive_and_play_video proc (if it's still running)...")
try:
os.killpg(self.__receive_and_play_video_proc_pgid, signal.SIGTERM)
except Exception:
# might raise: `ProcessLookupError: [Errno 3] No such process`
pass
Logger.set_uuid(self.__orig_log_uuid)
self.__is_video_playback_in_progress = False
self.__crop_args = None
self.__crop_args2 = None
# The first video that is played after a system restart appears to have a lag in starting,
# which can affect video synchronization across the receivers. Ensure we have played at
# least one video since system startup. This is a short, one-second video.
#
# Perhaps one thing this warmup video does is start the various dbus processes for the first
diff --git a/piwall2/receiver/receivercommandbuilder.py b/piwall2/receiver/receivercommandbuilder.py
index 2e00fc8..d4c3f64 100644
--- a/piwall2/receiver/receivercommandbuilder.py
+++ b/piwall2/receiver/receivercommandbuilder.py
@@ -1,45 +1,40 @@
import math
import shlex
from piwall2.directoryutils import DirectoryUtils
from piwall2.displaymode import DisplayMode
from piwall2.logger import Logger
import piwall2.receiver.receiver
from piwall2.volumecontroller import VolumeController
# Helper to build the "receive and play video" command
class ReceiverCommandBuilder:
def __init__(self, config_loader, receiver_config_stanza):
self.__logger = Logger().set_namespace(self.__class__.__name__)
self.__config_loader = config_loader
self.__receiver_config_stanza = receiver_config_stanza
- def build_receive_and_play_video_command_and_get_crop_args(
- self, log_uuid, video_width, video_height, volume_pct, display_mode, display_mode2
- ):
+ def build_receive_and_play_video_command(self, log_uuid, volume_pct):
adev, adev2 = self.__get_video_command_adev_args()
display, display2 = self.__get_video_command_display_args()
- crop_args, crop_args2 = self.__get_video_command_crop_args(video_width, video_height)
- crop = crop_args[display_mode]
- crop2 = crop_args2[display_mode2]
# See: https://github.com/popcornmix/omxplayer/#volume-rw
volume_pct = VolumeController.normalize_vol_pct(volume_pct)
if volume_pct == 0:
volume_millibels = VolumeController.GLOBAL_MIN_VOL_VAL
else:
volume_millibels = 2000 * math.log(volume_pct, 10)
"""
We use mbuffer in the receiver command. The mbuffer is here to solve two problems:
1) Sometimes the python receiver process would get blocked writing directly to omxplayer. When this happens,
the receiver's writes would occur rather slowly. While the receiver is blocked on writing, it cannot read
incoming data from the UDP socket. The kernel's UDP buffers would then fill up, causing UDP packets to be
dropped.
Unlike python, mbuffer is multithreaded, meaning it can read and write simultaneously in two separate
threads. Thus, while mbuffer is writing to omxplayer, it can still read the incoming data from python at
full speed. Slow writes will not block reads.
@@ -47,112 +42,68 @@ class ReceiverCommandBuilder:
% omxplayer --help
...
--audio_fifo n Size of audio output fifo in seconds
--video_fifo n Size of video output fifo in MB
--audio_queue n Size of audio input queue in MB
--video_queue n Size of video input queue in MB
...
More info: https://github.com/popcornmix/omxplayer/issues/256#issuecomment-57907940
I am not sure which I would need to adjust to ensure enough buffering is available. By using mbuffer,
we effectively have a single buffer that accounts for any possible source of delays, whether it's from audio,
video, and no matter where in the pipeline the delay is coming from. Using mbuffer seems simpler, and it is
easier to monitor. By checking its logs, we can see how close the mbuffer gets to becoming full.
"""
mbuffer_cmd = ('HOME=/home/pi mbuffer -q -l /tmp/mbuffer.out -m ' +
f'{piwall2.receiver.receiver.Receiver.VIDEO_PLAYBACK_MBUFFER_SIZE_BYTES}b')
# See: https://github.com/dasl-/piwall2/blob/main/docs/configuring_omxplayer.adoc
- omx_cmd_template = ('omxplayer --crop {0} --adev {1} --display {2} --vol {3} --aspect-mode stretch ' +
+ omx_cmd_template = ('omxplayer --adev {0} --display {1} --vol {2} --aspect-mode stretch ' +
'--no-keys --timeout 30 --threshold 0.2 --video_fifo 10 --genlog pipe:0')
omx_cmd = omx_cmd_template.format(
- shlex.quote(crop), shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels))
+ shlex.quote(adev), shlex.quote(display), shlex.quote(str(volume_millibels))
)
cmd = 'set -o pipefail && '
if self.__receiver_config_stanza['is_dual_video_output']:
omx_cmd2 = omx_cmd_template.format(
- shlex.quote(crop2), shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels))
+ shlex.quote(adev2), shlex.quote(display2), shlex.quote(str(volume_millibels))
)
cmd += f'{mbuffer_cmd} | tee >({omx_cmd}) >({omx_cmd2}) >/dev/null'
else:
cmd += f'{mbuffer_cmd} | {omx_cmd}'
receiver_cmd = (f'{DirectoryUtils().root_dir}/bin/receive_and_play_video --command {shlex.quote(cmd)} ' +
f'--log-uuid {shlex.quote(log_uuid)}')
- return (receiver_cmd, crop_args, crop_args2)
-
- def __get_video_command_adev_args(self):
- receiver_config = self.__receiver_config_stanza
- adev = None
- if receiver_config['audio'] == 'hdmi' or receiver_config['audio'] == 'hdmi0':
- adev = 'hdmi'
- elif receiver_config['audio'] == 'headphone':
- adev = 'local'
- elif receiver_config['audio'] == 'hdmi_alsa' or receiver_config['audio'] == 'hdmi0_alsa':
- adev = 'alsa:default:CARD=b1'
- else:
- raise Exception(f"Unexpected audio config value: {receiver_config['audio']}")
-
- adev2 = None
- if receiver_config['is_dual_video_output']:
- if receiver_config['audio2'] == 'hdmi1':
- adev2 = 'hdmi1'
- elif receiver_config['audio2'] == 'headphone':
- adev2 = 'local'
- elif receiver_config['audio'] == 'hdmi1_alsa':
- adev2 = 'alsa:default:CARD=b2'
- else:
- raise Exception(f"Unexpected audio2 config value: {receiver_config['audio2']}")
-
- return (adev, adev2)
-
- def __get_video_command_display_args(self):
- receiver_config = self.__receiver_config_stanza
- display = None
- if receiver_config['video'] == 'hdmi' or receiver_config['video'] == 'hdmi0':
- display = '2'
- elif receiver_config['video'] == 'composite':
- display = '3'
- else:
- raise Exception(f"Unexpected video config value: {receiver_config['video']}")
-
- display2 = None
- if receiver_config['is_dual_video_output']:
- if receiver_config['video2'] == 'hdmi1':
- display2 = '7'
- else:
- raise Exception(f"Unexpected video2 config value: {receiver_config['video2']}")
-
- return (display, display2)
+ return receiver_cmd
"""
Returns a set of crop args supporting two display modes: tile mode and repeat mode.
Tile mode is like this: https://i.imgur.com/BBrA1Cr.png
Repeat mode is like this: https://i.imgur.com/cpS61s8.png
We return four crop settings because for each mode, we calculate the crop arguments
for each of two TVs (each receiver can have at most two TVs hooked up to it).
"""
- def __get_video_command_crop_args(self, video_width, video_height):
+ def get_crop_dimensions(self, video_width, video_height):
receiver_config = self.__receiver_config_stanza
#####################################################################################
# Do tile mode calculations first ###################################################
#####################################################################################
wall_width = self.__config_loader.get_wall_width()
wall_height = self.__config_loader.get_wall_height()
displayable_video_width, displayable_video_height = (
self.__get_displayable_video_dimensions_for_screen(
video_width, video_height, wall_width, wall_height
)
)
x_offset = (video_width - displayable_video_width) / 2
y_offset = (video_height - displayable_video_height) / 2
x0 = round(x_offset + ((receiver_config['x'] / wall_width) * displayable_video_width))
y0 = round(y_offset + ((receiver_config['y'] / wall_height) * displayable_video_height))
x1 = round(x_offset + (((receiver_config['x'] + receiver_config['width']) / wall_width) * displayable_video_width))
y1 = round(y_offset + (((receiver_config['y'] + receiver_config['height']) / wall_height) * displayable_video_height))
@@ -212,40 +163,84 @@ class ReceiverCommandBuilder:
displayable_video_width, displayable_video_height = (
self.__get_displayable_video_dimensions_for_screen(
video_width, video_height, receiver_config['width2'], receiver_config['height2']
)
)
x_offset = (video_width - displayable_video_width) / 2
y_offset = (video_height - displayable_video_height) / 2
repeat_mode_crop2 = f"{x_offset} {y_offset} {x_offset + displayable_video_width} {y_offset + displayable_video_height}"
crop_args = {
DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop,
DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop,
}
crop_args2 = {
DisplayMode.DISPLAY_MODE_TILE: tile_mode_crop2,
DisplayMode.DISPLAY_MODE_REPEAT: repeat_mode_crop2,
}
return (crop_args, crop_args2)
+ def __get_video_command_adev_args(self):
+ receiver_config = self.__receiver_config_stanza
+ adev = None
+ if receiver_config['audio'] == 'hdmi' or receiver_config['audio'] == 'hdmi0':
+ adev = 'hdmi'
+ elif receiver_config['audio'] == 'headphone':
+ adev = 'local'
+ elif receiver_config['audio'] == 'hdmi_alsa' or receiver_config['audio'] == 'hdmi0_alsa':
+ adev = 'alsa:default:CARD=b1'
+ else:
+ raise Exception(f"Unexpected audio config value: {receiver_config['audio']}")
+
+ adev2 = None
+ if receiver_config['is_dual_video_output']:
+ if receiver_config['audio2'] == 'hdmi1':
+ adev2 = 'hdmi1'
+ elif receiver_config['audio2'] == 'headphone':
+ adev2 = 'local'
+ elif receiver_config['audio'] == 'hdmi1_alsa':
+ adev2 = 'alsa:default:CARD=b2'
+ else:
+ raise Exception(f"Unexpected audio2 config value: {receiver_config['audio2']}")
+
+ return (adev, adev2)
+
+ def __get_video_command_display_args(self):
+ receiver_config = self.__receiver_config_stanza
+ display = None
+ if receiver_config['video'] == 'hdmi' or receiver_config['video'] == 'hdmi0':
+ display = '2'
+ elif receiver_config['video'] == 'composite':
+ display = '3'
+ else:
+ raise Exception(f"Unexpected video config value: {receiver_config['video']}")
+
+ display2 = None
+ if receiver_config['is_dual_video_output']:
+ if receiver_config['video2'] == 'hdmi1':
+ display2 = '7'
+ else:
+ raise Exception(f"Unexpected video2 config value: {receiver_config['video2']}")
+
+ return (display, display2)
+
"""
The displayable width and height represents the section of the video that the wall will be
displaying. A section of these dimensions will be taken from the center of the original
video.
Currently, the piwall only supports displaying videos in "fill" mode (as opposed to
"letterbox" or "stretch"). This means that every portion of the TVs will be displaying
some section of the video (i.e. there will be no letterboxing). Furthermore, there will be
no warping of the video's aspect ratio. Instead, regions of the original video will be
cropped or stretched if necessary.
The units of the width and height arguments are not important. We are just concerned with
the aspect ratio. Thus, as long as video_width and video_height are in the same units
(probably pixels), and as long as screen_width and screen_height are in the same units
(probably inches or centimeters), everything will work.
The returned dimensions will be in the units of the inputted video_width and video_height
(probably pixels).
"""
def __get_displayable_video_dimensions_for_screen(self, video_width, video_height, screen_width, screen_height):
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment