Skip to content

Commit f649751

Browse files
beveradbclaude
andauthored
fix(vlc): converge VLC video placement below the ticker strip (v0.87.2) (#200)
v0.87.1's single-shot wmctrl correction mis-landed on the live karaoke window (y=160 instead of 80): VLC nudges/resizes its own window while a 4K file loads, so the one 0.4s-later measurement read a stale position and the lone correction was wrong. Replace it with a short closed loop — request, settle, measure, adjust the next request by the observed error — that converges to (0, margin) within a 2px tolerance, and soft-warns (never falsely claims success) if it can't settle. Validated end-to-end on NomadPC: VLC video settles at 1920x1000+0+80, matching mpv. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4d26ce commit f649751

5 files changed

Lines changed: 71 additions & 26 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ The overlay system uses a three-component architecture: (1) the KJ Controller we
298298

299299
**Layers:** `desktop/rotation_source.py` (pure stdlib) parses `/tmp/rotation_cache.json` into structured data; `desktop/overlay_painters.py` (pure pycairo, no GTK — headless-testable) holds one painter per overlay type; `desktop/overlay_engine.py` (the only `gi`/GTK module) owns the window + render loop and a gi-free `--render-png` mode for headless/on-device visual checks. Communication with the Flask backend is via `data/overlays.json` polled by mtime every ~1 second. Six overlay types are supported: `rotation_list` (the between-songs home screen — heading, stats, singer list with status badges/paid hearts, page cycling), `ticker` (scrolling bar; `source='rotation'` composes the "up next" text directly from the rotation cache; loops seamlessly — a configurable `loop_separator` glyph, default `♪`, is appended to form a repeating unit that tiles back-to-back so there's no blank gap after the last singer), `static_text`, `image`, `countdown`, and `qr_code`. Each overlay has an independent `show_over_video` flag — when false it is hidden during karaoke playback (e.g. the rotation list) and shown when playback stops. The `karaoke_playing` state is set by the play/control routes and a `MpvManager.on_karaoke_end` callback.
300300

301-
**Partial redraw + reserved top strip (4K frame-drop fix, 2026-07):** the render loop invalidates only each **animated overlay's own bounding box** (`queue_draw_area(*painter.bbox())`), not the whole window. Previously every ticker frame called `queue_draw()`, forcing the compositor to re-blend the entire screen — including the 4K video region — 30×/s, which cost measurable frame drops on the N97 iGPU. Complementing this, the karaoke video is rendered **below a reserved top strip** (`video_top_margin_px`, default 80): `mpv_manager` launches mpv borderless at `--geometry=<W>x<H-margin>+0+<margin>` (was `--fs`; margin 0 restores fullscreen for rollback), so a top-strip ticker's damage rect never overlaps the video window and the compositor stops re-blending video pixels for the ticker entirely. This is a **persisted cross-process contract, not runtime IPC**: kj-controller writes `video_top_margin_px` into `overlays.json` (`OverlayManager.set_video_top_margin`, at startup) and sizes the video from the same `config.py` value; the overlay engine reads that strip height back out (`load_config` injects it into each ticker's config as `_strip_h`) and a `position:'top'` ticker sizes its bar to **fill** the strip — so there's no wallpaper gap between the ticker and the video, and the ticker's damage rect is exactly the reserved strip. Both sides independently read one persisted setting; `video_top_margin_px` is the single source of truth for both the video geometry and the ticker height, so they can't drift. VLC honours the same strip (device-validated 2026-07): it launches windowed and — because it maps its video window only once a song plays and ignores its own geometry CLI flags — `VlcKaraokePlayer._position_window` places the window **per-play** with `wmctrl`, matched by the unambiguous `VLC media player` title (the filler VLC is audio-only with no window; VLC leaves `_NET_WM_PID` unset, so the title is the only key). It corrects xfwm4's fixed frame-extent offset by measuring where the window lands after the first move and re-requesting `2×target − actual`. `margin_px <= 0` restores fullscreen for both engines (clean rollback). mpv remains the default and the only engine that can *hardware-decode* 4K on this box — VLC 3.0's VAAPI decoder never engages, so VLC always software-decodes (fine at 1080p, glitchy at 4K).
301+
**Partial redraw + reserved top strip (4K frame-drop fix, 2026-07):** the render loop invalidates only each **animated overlay's own bounding box** (`queue_draw_area(*painter.bbox())`), not the whole window. Previously every ticker frame called `queue_draw()`, forcing the compositor to re-blend the entire screen — including the 4K video region — 30×/s, which cost measurable frame drops on the N97 iGPU. Complementing this, the karaoke video is rendered **below a reserved top strip** (`video_top_margin_px`, default 80): `mpv_manager` launches mpv borderless at `--geometry=<W>x<H-margin>+0+<margin>` (was `--fs`; margin 0 restores fullscreen for rollback), so a top-strip ticker's damage rect never overlaps the video window and the compositor stops re-blending video pixels for the ticker entirely. This is a **persisted cross-process contract, not runtime IPC**: kj-controller writes `video_top_margin_px` into `overlays.json` (`OverlayManager.set_video_top_margin`, at startup) and sizes the video from the same `config.py` value; the overlay engine reads that strip height back out (`load_config` injects it into each ticker's config as `_strip_h`) and a `position:'top'` ticker sizes its bar to **fill** the strip — so there's no wallpaper gap between the ticker and the video, and the ticker's damage rect is exactly the reserved strip. Both sides independently read one persisted setting; `video_top_margin_px` is the single source of truth for both the video geometry and the ticker height, so they can't drift. VLC honours the same strip (device-validated 2026-07): it launches windowed and — because it maps its video window only once a song plays and ignores its own geometry CLI flags — `VlcKaraokePlayer._position_window` places the window **per-play** with `wmctrl`, matched by the unambiguous `VLC media player` title (the filler VLC is audio-only with no window; VLC leaves `_NET_WM_PID` unset, so the title is the only key). It drives a short **closed loop** (request → settle → measure → adjust the next request by the observed error) until the window lands within 2px of target — needed because xfwm4 offsets wmctrl moves by a fixed frame-extent amount *and* VLC nudges/resizes its own window while a 4K file loads, so a single measure-and-correct can act on a stale position. `margin_px <= 0` restores fullscreen for both engines (clean rollback). mpv remains the default and the only engine that can *hardware-decode* 4K on this box — VLC 3.0's VAAPI decoder never engages, so VLC always software-decodes (fine at 1080p, glitchy at 4K).
302302

303303
### Singer Rotation System
304304

‎docs/CHANGELOG.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
Device configuration changes. For Pi details, see [archive/NOMADPI-DETAILS.md](archive/NOMADPI-DETAILS.md). For mini PC setup, see [MINIPC-SETUP.md](MINIPC-SETUP.md).
44

5-
## 2026-07-17 - Fix: VLC video no longer covered by the rotation ticker (v0.87.1)
5+
## 2026-07-17 - Fix: VLC video no longer covered by the rotation ticker (v0.87.2)
66

77
The VLC karaoke renderer now honours the reserved top ticker strip
88
(`video_top_margin_px`, default 80) just like mpv — previously VLC launched
99
fullscreen and the ticker composited over the top of its video, while mpv
1010
rendered below the strip. VLC ignores its own geometry CLI flags and maps its
1111
video window only when a song starts, so `VlcKaraokePlayer._position_window`
1212
now places the window **per-play** with `wmctrl` (matched by the `VLC media
13-
player` window title), correcting xfwm4's fixed frame-extent offset by
14-
re-requesting `2×target − actual`. Placement validated on NomadPC by
15-
screenshot: VLC video sits at `1920×1000+0+80`, matching mpv.
13+
player` window title). Because xfwm4 offsets wmctrl moves by a fixed
14+
frame-extent amount *and* VLC nudges its own window while a 4K file loads, it
15+
drives a short closed loop (request → settle → measure → adjust by the observed
16+
error) until the window lands within 2px of target. Validated end-to-end on
17+
NomadPC: VLC video settles at `1920×1000+0+80`, matching mpv. (v0.87.1 shipped
18+
the per-play placement but a single-shot correction mis-landed on the live 4K
19+
window at `y=160`; v0.87.2 replaced it with the convergence loop.)
1620

1721
Investigation context (no code change): confirmed **VLC 3.0.20 hardware-decodes
1822
nothing via VAAPI** on NomadPC — its decoder module declines every codec, so

‎kj-controller/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "kj-controller"
3-
version = "0.87.1"
3+
version = "0.87.2"
44
description = "Web-based karaoke show management with mpv + VLC playback"
55
requires-python = ">=3.11"
66

‎kj-controller/tests/unit/test_vlc_karaoke_player.py‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,20 +447,42 @@ def test_play_skips_positioning_without_margin(mock_config, mocker, tmp_path):
447447
def test_position_window_self_corrects_wm_offset(mock_config, mocker):
448448
filler = FillerVLC(mock_config, enabled=False)
449449
p = VlcKaraokePlayer(mock_config, filler, enabled=True)
450-
# Window found immediately; after the first move it lands offset at (4, 152)
451-
# (xfwm4's fixed frame-extent skew).
450+
# Poll finds the window; the first move to (0,80) lands offset at (4,152)
451+
# (xfwm4's fixed frame-extent skew); the corrected request then lands on
452+
# target so the loop stops.
452453
mocker.patch.object(p, '_find_window',
453-
side_effect=[('0xwin', 0, 0), ('0xwin', 4, 152)])
454+
side_effect=[('0xwin', 0, 0), # poll: found
455+
('0xwin', 4, 152), # after req (0,80)
456+
('0xwin', 0, 80)]) # after req (-4,8): done
454457
wmctrl = mocker.patch.object(p, '_wmctrl',
455458
return_value=mocker.Mock(returncode=0))
456459
mocker.patch('vlc.subprocess.run') # xprop decoration removal
457460
mocker.patch('vlc.time.sleep')
461+
logs = mocker.patch('vlc.log_message')
458462
p._position_window(80, 1920, 1080)
459463
moves = [c.args[0] for c in wmctrl.call_args_list]
460-
# First move requests the raw target; the correction requests 2*target-actual
461-
# so the window lands exactly at (0, 80).
464+
# First request is the raw target; the next adjusts by the observed error
465+
# (req += target - actual) so the window lands exactly at (0, 80).
462466
assert ['-i', '-r', '0xwin', '-e', '0,0,80,1920,1000'] in moves
463467
assert ['-i', '-r', '0xwin', '-e', '0,-4,8,1920,1000'] in moves
468+
msgs = ' '.join(str(c.args[0]) for c in logs.call_args_list)
469+
assert 'Positioned karaoke VLC' in msgs
470+
471+
472+
def test_position_window_soft_warns_when_never_settles(mock_config, mocker):
473+
# If the window never reaches target (keeps drifting), the loop gives up
474+
# after its max iterations with a soft warning — never claims success.
475+
filler = FillerVLC(mock_config, enabled=False)
476+
p = VlcKaraokePlayer(mock_config, filler, enabled=True)
477+
mocker.patch.object(p, '_find_window', return_value=('0xwin', 500, 500))
478+
mocker.patch.object(p, '_wmctrl', return_value=mocker.Mock(returncode=0))
479+
mocker.patch('vlc.subprocess.run')
480+
mocker.patch('vlc.time.sleep')
481+
logs = mocker.patch('vlc.log_message')
482+
p._position_window(80, 1920, 1080)
483+
msgs = ' '.join(str(c.args[0]) for c in logs.call_args_list)
484+
assert 'did not settle' in msgs
485+
assert 'Positioned karaoke VLC' not in msgs
464486

465487

466488
def test_position_window_bails_on_wmctrl_nonzero_exit(mock_config, mocker):

‎kj-controller/vlc.py‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -249,22 +249,41 @@ def _position_window(self, margin_px, screen_w, screen_h):
249249
['-i', '-r', winid, '-b',
250250
'remove,maximized_vert,maximized_horz']):
251251
return
252-
if not self._wmctrl_ok(
253-
['-i', '-r', winid, '-e',
254-
f'0,0,{margin_px},{screen_w},{video_h}']):
255-
return
256-
time.sleep(0.4)
257-
cur = self._find_window()
258-
if cur:
252+
253+
# Converge on (0, margin_px). Two things move the window off a single
254+
# request: xfwm4 offsets wmctrl moves by a fixed frame-extent amount, and
255+
# VLC nudges/resizes its own window while a (4K) file loads. So we drive a
256+
# closed loop — request, settle, measure, and adjust the *next* request by
257+
# the observed error (req += target - actual) — until the window lands on
258+
# target within a 2px tolerance (covers the WM border). A stale read one
259+
# round is simply corrected the next.
260+
req_x, req_y = 0, margin_px
261+
placed = False
262+
last = None
263+
for _ in range(6):
264+
if not self._wmctrl_ok(
265+
['-i', '-r', winid, '-e',
266+
f'0,{req_x},{req_y},{screen_w},{video_h}']):
267+
return
268+
time.sleep(0.5)
269+
cur = self._find_window()
270+
if not cur:
271+
break
259272
_, ax, ay = cur
260-
# Correct the fixed WM offset: request 2*target - actual so the
261-
# window lands exactly at (0, margin_px). target_x is 0. Best-effort
262-
# refinement — the primary placement above already succeeded.
263-
self._wmctrl_ok(
264-
['-i', '-r', winid, '-e',
265-
f'0,{-ax},{2 * margin_px - ay},{screen_w},{video_h}'])
266-
log_message(
267-
f"Positioned karaoke VLC below {margin_px}px ticker strip.", self.config)
273+
last = (ax, ay)
274+
if abs(ax) <= 2 and abs(ay - margin_px) <= 2:
275+
placed = True
276+
break
277+
req_x += -ax
278+
req_y += margin_px - ay
279+
if placed:
280+
log_message(
281+
f"Positioned karaoke VLC below {margin_px}px ticker strip.",
282+
self.config)
283+
else:
284+
log_message(
285+
f"Karaoke VLC placement did not settle (last={last}) — video may "
286+
f"be slightly off the {margin_px}px strip.", self.config)
268287

269288
# ── Lifecycle ──────────────────────────────────────────────────────
270289

0 commit comments

Comments
 (0)