Add screenshot consent notice and improve video frame/audio handling
- add required dashboard screenshot consent/privacy notice to README - update copilot instructions to preserve consent notice in docs - document external VLC audio behavior (`--no-audio` for muted/0%, `--gain` otherwise) - capture real video frames for dashboard screenshots instead of black compositor output - make ffmpeg fallback frame capture playback-relative (loop-aware) to avoid repeated stale image
This commit is contained in:
@@ -564,6 +564,7 @@ class DisplayManager:
|
||||
self.running = True
|
||||
self.client_settings_mtime: Optional[float] = None
|
||||
self.client_volume_multiplier = 1.0
|
||||
self._video_duration_cache: Dict[str, float] = {}
|
||||
|
||||
# Initialize health state tracking for process monitoring
|
||||
self.health = ProcessHealthState()
|
||||
@@ -644,6 +645,9 @@ class DisplayManager:
|
||||
|
||||
def _calculate_video_volume_pct(self, video: Dict) -> int:
|
||||
"""Combine event volume with the client-level multiplier for VLC."""
|
||||
if video.get('muted', False):
|
||||
logging.info("Video volume resolved: muted=True, effective=0%")
|
||||
return 0
|
||||
self._load_client_settings()
|
||||
event_volume = self._normalize_volume_level(video.get('volume'), default=1.0)
|
||||
effective_volume = event_volume * self.client_volume_multiplier
|
||||
@@ -1128,9 +1132,14 @@ class DisplayManager:
|
||||
if not vlc:
|
||||
# Fallback to launching external vlc binary
|
||||
if self._command_exists('vlc'):
|
||||
if vol_pct != 100:
|
||||
logging.warning(
|
||||
"External VLC fallback does not support reliable per-event startup volume here; requested effective volume is %d%%",
|
||||
external_muted = bool(video.get('muted', False) or vol_pct == 0)
|
||||
external_gain = max(0.0, min(1.0, vol_pct / 100.0))
|
||||
if external_muted:
|
||||
logging.info("External VLC fallback: forcing mute via --no-audio")
|
||||
else:
|
||||
logging.info(
|
||||
"External VLC fallback: applying startup gain %.2f from effective volume %d%%",
|
||||
external_gain,
|
||||
vol_pct,
|
||||
)
|
||||
cmd = [
|
||||
@@ -1140,6 +1149,11 @@ class DisplayManager:
|
||||
'--loop' if video.get('loop', False) else '--play-and-exit',
|
||||
video_url
|
||||
]
|
||||
if external_muted:
|
||||
cmd.insert(-1, '--no-audio')
|
||||
else:
|
||||
# VLC CLI gain is a linear multiplier where 1.0 is default.
|
||||
cmd.insert(-1, f'--gain={external_gain:.2f}')
|
||||
video_log_path = os.path.join(os.path.dirname(LOG_PATH), 'video_player.out.log')
|
||||
os.makedirs(os.path.dirname(video_log_path), exist_ok=True)
|
||||
video_log = open(video_log_path, 'ab', buffering=0)
|
||||
@@ -1167,7 +1181,13 @@ class DisplayManager:
|
||||
# Use libvlc via python-vlc
|
||||
try:
|
||||
# Keep python-vlc behavior aligned with external vlc fullscreen mode.
|
||||
instance = vlc.Instance('--fullscreen', '--no-video-title-show')
|
||||
_muted = video.get('muted', False) or vol_pct == 0
|
||||
_instance_args = ['--fullscreen', '--no-video-title-show']
|
||||
if _muted:
|
||||
# Disable audio output entirely at the instance level to avoid
|
||||
# the race between async audio init and audio_set_volume(0).
|
||||
_instance_args.append('--no-audio')
|
||||
instance = vlc.Instance(*_instance_args)
|
||||
|
||||
def _force_fullscreen(player_obj, label: str):
|
||||
"""Retry fullscreen toggle because video outputs may attach asynchronously."""
|
||||
@@ -1189,42 +1209,29 @@ class DisplayManager:
|
||||
loop_flag = bool(video.get('loop', False))
|
||||
|
||||
if loop_flag:
|
||||
# Use MediaListPlayer for looped playback
|
||||
mlp = vlc.MediaListPlayer()
|
||||
# Use a plain MediaPlayer with :input-repeat so everything stays on
|
||||
# our custom `instance` (e.g. with --no-audio when muted).
|
||||
# MediaListPlayer() would create its own default instance,
|
||||
# bypassing flags like --no-audio.
|
||||
mp = instance.media_player_new()
|
||||
mlp.set_media_player(mp)
|
||||
# Create media list with the single URL
|
||||
try:
|
||||
ml = instance.media_list_new([video_url])
|
||||
except Exception:
|
||||
# Fallback: create empty and add media
|
||||
ml = instance.media_list_new()
|
||||
m = instance.media_new(video_url)
|
||||
ml.add_media(m)
|
||||
m = instance.media_new(video_url)
|
||||
m.add_option(':input-repeat=65535') # ~infinite loop
|
||||
mp.set_media(m)
|
||||
|
||||
mlp.set_media_list(ml)
|
||||
try:
|
||||
# Set loop playback mode if available
|
||||
mlp.set_playback_mode(vlc.PlaybackMode.loop)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set volume on underlying media player
|
||||
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer')
|
||||
self._apply_vlc_volume(mp, vol_pct, 'python-vlc loop MediaPlayer')
|
||||
|
||||
if autoplay:
|
||||
try:
|
||||
mlp.play()
|
||||
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer', retries=True)
|
||||
_force_fullscreen(mp, 'python-vlc MediaListPlayer')
|
||||
mp.play()
|
||||
self._apply_vlc_volume(mp, vol_pct, 'python-vlc loop MediaPlayer', retries=True)
|
||||
_force_fullscreen(mp, 'python-vlc loop MediaPlayer')
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to play media list: {e}")
|
||||
logging.error(f"Failed to start loop media player: {e}")
|
||||
event_id = self.get_event_identifier(event)
|
||||
runtime_pid = os.getpid()
|
||||
logging.info(f"Video started via python-vlc (MediaListPlayer), runtime PID: {runtime_pid}")
|
||||
# python-vlc runs in-process (no external vlc child), so publish this process PID.
|
||||
logging.info(f"Video started via python-vlc (loop MediaPlayer), runtime PID: {runtime_pid}")
|
||||
self.health.update_running(event_id, 'video', 'vlc', runtime_pid)
|
||||
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mlp, volume_pct=vol_pct)
|
||||
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mp, volume_pct=vol_pct)
|
||||
|
||||
else:
|
||||
# Single-play MediaPlayer
|
||||
@@ -1766,9 +1773,14 @@ class DisplayManager:
|
||||
display_env = os.environ.get('DISPLAY')
|
||||
logging.debug(f"Screenshot session={session} DISPLAY={display_env} WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY')}")
|
||||
|
||||
# Video planes can be black in compositor screenshots; try direct frame extraction first.
|
||||
if self._capture_video_frame(raw_path):
|
||||
captured = True
|
||||
logging.debug("Screenshot source: direct video frame extraction")
|
||||
|
||||
if session == 'wayland':
|
||||
# 1W: grim (wayland/sway, wlroots) captures root
|
||||
if command_exists('grim'):
|
||||
if not captured and command_exists('grim'):
|
||||
cmd = ['grim', raw_path]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
|
||||
if result.returncode == 0 and os.path.exists(raw_path):
|
||||
@@ -1794,7 +1806,7 @@ class DisplayManager:
|
||||
else:
|
||||
# X11 path
|
||||
# 1X: scrot
|
||||
if command_exists('scrot'):
|
||||
if not captured and command_exists('scrot'):
|
||||
cmd = ['scrot', raw_path]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
|
||||
if result.returncode == 0 and os.path.exists(raw_path):
|
||||
@@ -1886,6 +1898,125 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logging.debug(f"Screenshot capture failure: {e}")
|
||||
|
||||
def _capture_video_frame(self, target_path: str) -> bool:
|
||||
"""Capture a frame from the active video event directly.
|
||||
|
||||
Returns True when a frame was successfully written to target_path.
|
||||
"""
|
||||
try:
|
||||
if not self.current_process or self.current_process.event_type != 'video':
|
||||
return False
|
||||
|
||||
# Prefer python-vlc direct snapshot when available.
|
||||
player = getattr(self.current_process, 'player', None)
|
||||
if player:
|
||||
media_player = None
|
||||
if hasattr(player, 'video_take_snapshot'):
|
||||
media_player = player
|
||||
elif hasattr(player, 'get_media_player'):
|
||||
try:
|
||||
media_player = player.get_media_player()
|
||||
except Exception:
|
||||
media_player = None
|
||||
|
||||
if media_player and hasattr(media_player, 'video_take_snapshot'):
|
||||
try:
|
||||
rc = media_player.video_take_snapshot(0, target_path, 0, 0)
|
||||
if rc == 0 and os.path.exists(target_path) and os.path.getsize(target_path) > 0:
|
||||
return True
|
||||
logging.debug(f"python-vlc snapshot failed with rc={rc}")
|
||||
except Exception as e:
|
||||
logging.debug(f"python-vlc snapshot error: {e}")
|
||||
|
||||
# Fallback: extract one frame from source URL using ffmpeg.
|
||||
# This covers external VLC fallback mode where no python-vlc player object exists.
|
||||
if not self._command_exists('ffmpeg'):
|
||||
return False
|
||||
|
||||
video = self.current_event_data.get('video', {}) if isinstance(self.current_event_data, dict) else {}
|
||||
source_url = video.get('url') if isinstance(video, dict) else None
|
||||
source_url = self._resolve_file_url(source_url) if source_url else None
|
||||
if not source_url:
|
||||
return False
|
||||
|
||||
loop_enabled = bool(video.get('loop', False)) if isinstance(video, dict) else False
|
||||
elapsed_seconds = 0.0
|
||||
try:
|
||||
elapsed_seconds = max(
|
||||
0.0,
|
||||
(datetime.now(timezone.utc) - self.current_process.start_time).total_seconds()
|
||||
)
|
||||
except Exception:
|
||||
elapsed_seconds = 0.0
|
||||
|
||||
# Use a playback-relative seek point so repeated captures are not the same frame.
|
||||
seek_seconds = max(0.2, elapsed_seconds)
|
||||
|
||||
duration_seconds = self._get_video_duration_seconds(source_url)
|
||||
if duration_seconds and duration_seconds > 1.0:
|
||||
safe_span = max(0.5, duration_seconds - 0.5)
|
||||
if loop_enabled:
|
||||
seek_seconds = 0.25 + (elapsed_seconds % safe_span)
|
||||
else:
|
||||
seek_seconds = min(seek_seconds, safe_span)
|
||||
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-ss', f'{seek_seconds:.3f}',
|
||||
'-i', source_url,
|
||||
'-frames:v', '1',
|
||||
target_path,
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
||||
if result.returncode == 0 and os.path.exists(target_path) and os.path.getsize(target_path) > 0:
|
||||
logging.debug(
|
||||
"Video frame extracted via ffmpeg: seek=%.3fs elapsed=%.3fs duration=%s loop=%s",
|
||||
seek_seconds,
|
||||
elapsed_seconds,
|
||||
f"{duration_seconds:.3f}s" if duration_seconds else "unknown",
|
||||
loop_enabled,
|
||||
)
|
||||
return True
|
||||
|
||||
logging.debug(f"ffmpeg frame capture failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:160]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.debug(f"Direct video frame capture failed: {e}")
|
||||
return False
|
||||
|
||||
def _get_video_duration_seconds(self, source_url: str) -> Optional[float]:
|
||||
"""Return media duration in seconds for a video URL, using a small in-memory cache."""
|
||||
try:
|
||||
cached = self._video_duration_cache.get(source_url)
|
||||
if cached and cached > 0:
|
||||
return cached
|
||||
|
||||
if not self._command_exists('ffprobe'):
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
'ffprobe',
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
source_url,
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
raw = result.stdout.decode('utf-8', errors='ignore').strip()
|
||||
duration = float(raw)
|
||||
if duration > 0:
|
||||
self._video_duration_cache[source_url] = duration
|
||||
return duration
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point"""
|
||||
|
||||
Reference in New Issue
Block a user