webatem 2.3.0__tar.gz → 2.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. {webatem-2.3.0 → webatem-2.3.2}/PKG-INFO +1 -1
  2. webatem-2.3.2/atem_control/tally.py +238 -0
  3. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control.html +34 -14
  4. {webatem-2.3.0 → webatem-2.3.2}/atem_control/uploader.py +10 -26
  5. {webatem-2.3.0 → webatem-2.3.2}/pyproject.toml +1 -1
  6. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/PKG-INFO +1 -1
  7. webatem-2.3.0/atem_control/tally.py +0 -141
  8. {webatem-2.3.0 → webatem-2.3.2}/LICENSE +0 -0
  9. {webatem-2.3.0 → webatem-2.3.2}/README.md +0 -0
  10. {webatem-2.3.0 → webatem-2.3.2}/atem_control/__init__.py +0 -0
  11. {webatem-2.3.0 → webatem-2.3.2}/atem_control/activity.py +0 -0
  12. {webatem-2.3.0 → webatem-2.3.2}/atem_control/apps.py +0 -0
  13. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/__init__.py +0 -0
  14. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/activity.py +0 -0
  15. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/commands.py +0 -0
  16. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/consumer.py +0 -0
  17. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/device_api.py +0 -0
  18. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/logging.py +0 -0
  19. {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/views.py +0 -0
  20. {webatem-2.3.0 → webatem-2.3.2}/atem_control/discovery.py +0 -0
  21. {webatem-2.3.0 → webatem-2.3.2}/atem_control/hooks.py +0 -0
  22. {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/__init__.py +0 -0
  23. {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/connection.py +0 -0
  24. {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/views.py +0 -0
  25. {webatem-2.3.0 → webatem-2.3.2}/atem_control/ip_upload_lock.py +0 -0
  26. {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/__init__.py +0 -0
  27. {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/broadcast.py +0 -0
  28. {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/still_cache.py +0 -0
  29. {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/views.py +0 -0
  30. {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/watcher.py +0 -0
  31. {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/0001_initial.py +0 -0
  32. {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/0002_legacy_log.py +0 -0
  33. {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/__init__.py +0 -0
  34. {webatem-2.3.0 → webatem-2.3.2}/atem_control/models.py +0 -0
  35. {webatem-2.3.0 → webatem-2.3.2}/atem_control/netutil.py +0 -0
  36. {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/__init__.py +0 -0
  37. {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/dialog.py +0 -0
  38. {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/export.py +0 -0
  39. {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/views.py +0 -0
  40. {webatem-2.3.0 → webatem-2.3.2}/atem_control/routing.py +0 -0
  41. {webatem-2.3.0 → webatem-2.3.2}/atem_control/sightings.py +0 -0
  42. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/apple-touch-icon.png +0 -0
  43. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/brand.css +0 -0
  44. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/favicon-32.png +0 -0
  45. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/favicon.ico +0 -0
  46. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-400-latin-ext.woff2 +0 -0
  47. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-400-latin.woff2 +0 -0
  48. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-500-latin-ext.woff2 +0 -0
  49. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-500-latin.woff2 +0 -0
  50. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-600-latin-ext.woff2 +0 -0
  51. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-600-latin.woff2 +0 -0
  52. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-800-latin-ext.woff2 +0 -0
  53. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-800-latin.woff2 +0 -0
  54. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
  55. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-400-latin.woff2 +0 -0
  56. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
  57. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-500-latin.woff2 +0 -0
  58. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2 +0 -0
  59. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-600-latin.woff2 +0 -0
  60. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/OFL-Archivo.txt +0 -0
  61. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/OFL-IBMPlexMono.txt +0 -0
  62. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-192.png +0 -0
  63. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-512.png +0 -0
  64. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-maskable-512.png +0 -0
  65. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/manifest.webmanifest +0 -0
  66. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/mark.svg +0 -0
  67. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/linux-24.png +0 -0
  68. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/mac-22.png +0 -0
  69. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/mac-44.png +0 -0
  70. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/win-16.png +0 -0
  71. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/win-32.png +0 -0
  72. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/css/atem_connect.css +0 -0
  73. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/css/atem_control.css +0 -0
  74. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/icon.png +0 -0
  75. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_connect.js +0 -0
  76. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_control.js +0 -0
  77. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_control_page.js +0 -0
  78. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_discovery.js +0 -0
  79. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_hyperdeck.js +0 -0
  80. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_profile.js +0 -0
  81. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_realtime_slider.js +0 -0
  82. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/custom-scrollbar.js +0 -0
  83. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/recent_label.js +0 -0
  84. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/wa-select.js +0 -0
  85. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/README.md +0 -0
  86. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/alpine.min.js +0 -0
  87. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
  88. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
  89. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  90. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  91. {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/webatem.css +0 -0
  92. {webatem-2.3.0 → webatem-2.3.2}/atem_control/storage.py +0 -0
  93. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/connect.html +0 -0
  94. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_atem_info.html +0 -0
  95. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_fade_to_black.html +0 -0
  96. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_fairlight.html +0 -0
  97. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_header.html +0 -0
  98. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
  99. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_macros.html +0 -0
  100. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_media_pool.html +0 -0
  101. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_profile_modal.html +0 -0
  102. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_dsk.html +0 -0
  103. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_transition.html +0 -0
  104. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_usk.html +0 -0
  105. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_slider.html +0 -0
  106. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_transition_controls.html +0 -0
  107. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_transition_style.html +0 -0
  108. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_fly.html +0 -0
  109. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_keyframe.html +0 -0
  110. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_mask.html +0 -0
  111. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_slider.html +0 -0
  112. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templatetags/__init__.py +0 -0
  113. {webatem-2.3.0 → webatem-2.3.2}/atem_control/templatetags/static_v.py +0 -0
  114. {webatem-2.3.0 → webatem-2.3.2}/atem_control/urls.py +0 -0
  115. {webatem-2.3.0 → webatem-2.3.2}/setup.cfg +0 -0
  116. {webatem-2.3.0 → webatem-2.3.2}/webatem/__init__.py +0 -0
  117. {webatem-2.3.0 → webatem-2.3.2}/webatem/__main__.py +0 -0
  118. {webatem-2.3.0 → webatem-2.3.2}/webatem/applog.py +0 -0
  119. {webatem-2.3.0 → webatem-2.3.2}/webatem/asgi.py +0 -0
  120. {webatem-2.3.0 → webatem-2.3.2}/webatem/context.py +0 -0
  121. {webatem-2.3.0 → webatem-2.3.2}/webatem/launcher.py +0 -0
  122. {webatem-2.3.0 → webatem-2.3.2}/webatem/server.py +0 -0
  123. {webatem-2.3.0 → webatem-2.3.2}/webatem/settings.py +0 -0
  124. {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/base.html +0 -0
  125. {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/brand/_mark.svg +0 -0
  126. {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/launcher.html +0 -0
  127. {webatem-2.3.0 → webatem-2.3.2}/webatem/uninstall.py +0 -0
  128. {webatem-2.3.0 → webatem-2.3.2}/webatem/updates.py +0 -0
  129. {webatem-2.3.0 → webatem-2.3.2}/webatem/urls.py +0 -0
  130. {webatem-2.3.0 → webatem-2.3.2}/webatem/views.py +0 -0
  131. {webatem-2.3.0 → webatem-2.3.2}/webatem/websocket.py +0 -0
  132. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/SOURCES.txt +0 -0
  133. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/dependency_links.txt +0 -0
  134. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/entry_points.txt +0 -0
  135. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/requires.txt +0 -0
  136. {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webatem
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there
5
5
  Author: Lucas Romanenko
6
6
  License: MIT
@@ -0,0 +1,238 @@
1
+ """Shared tally cycle-wait primitive.
2
+
3
+ Both the still / media-pool uploader (``uploader._wait_for_safe_upload_window``)
4
+ and the HyperDeck clip path (``hyperdeck_tally.wait_for_safe_window``) gate their
5
+ mutations on the same cold -> hot -> cold cycle. That loop and its timings live
6
+ here so the two callers can't drift; each supplies its own liveness predicate (a
7
+ media-pool slot vs a deck's switcher input) and its own log label. Whether the
8
+ switcher's state was there to read is the cycle's own question, not the
9
+ predicate's: a session that drops inside the cycle starts it again.
10
+ """
11
+
12
+ import time
13
+ from typing import Callable, Optional
14
+
15
+ from atemwire.transport import Wakeup
16
+
17
+ # Tally cycle-wait timings — single source of truth for every caller.
18
+ TALLY_POLL_INTERVAL = 0.05
19
+ TALLY_OBSERVATION_SECONDS = 40.0
20
+ TALLY_TIMEOUT = 300.0
21
+
22
+
23
+ def ticked_pump(protocol, idle_sleep: float = 0.0) -> None:
24
+ """One ``protocol.loop()`` that is GUARANTEED to return.
25
+
26
+ A bare ``loop()`` blocks in ``thread_recv_queue.get()`` until a
27
+ consumer-visible packet arrives — and on a quiet established session
28
+ none ever does (keepalive pings are consumed at transport level, and
29
+ their arrival keeps the UDP thread's select timeout from firing).
30
+ Every deadline/cancel check "between loop() calls" is unenforceable
31
+ against that block: the uploader's 0.3s flush, the 90s upload timeout
32
+ and the 300s tally timeout could all hang a replica forever (found
33
+ live by lock_hygiene_smoke, 2026-07-06).
34
+
35
+ When the recv queue is idle, enqueue a ``Wakeup`` sentinel (the same
36
+ trick ATEMConnection.send() uses) and optionally pace with
37
+ ``idle_sleep``; when packets are queued, drain at full speed with no
38
+ tick and no sleep (the 2026-07-02 lesson: a fixed sleep per pump
39
+ throttled 6000-packet transfers from ~2s to 60s+).
40
+ """
41
+ idle = True
42
+ try:
43
+ q = protocol.transport.thread_recv_queue
44
+ # "Idle" cannot be judged by qsize(): on an ESTABLISHED session the
45
+ # ATEM's keepalive pings land in this queue every second, and
46
+ # ``receive_packet`` swallows those control packets with a
47
+ # ``continue`` — straight into another blocking ``get()``. So a
48
+ # queue that is never empty (always a ping in it) meant no sentinel
49
+ # was ever queued, and loop() ate ping after ping until some other
50
+ # session made the switcher emit a data packet — minutes, or never.
51
+ # Found live: a HyperDeck job stalled at its post-connect pump
52
+ # (2026-09-09), blocked 90 s until a second session connected.
53
+ # A Wakeup behind a SHORT queue costs one early return; a long queue
54
+ # is a real transfer and gets no sentinel (the 2026-07-02 lesson).
55
+ idle = q.qsize() <= 8
56
+ if idle:
57
+ q.put(Wakeup())
58
+ except Exception:
59
+ # No real transport (test fakes) — treat as idle so the pacing
60
+ # sleep below still runs (tests drive a virtual clock through it).
61
+ pass
62
+ if idle and idle_sleep:
63
+ time.sleep(idle_sleep)
64
+ protocol.loop()
65
+
66
+
67
+ def _pooled(conn) -> bool:
68
+ """A pooled ``ATEMConnection`` (how a host calls ``_is_slot_safe``), not
69
+ a bare ``AtemProtocol``: its worker thread pumps the loop and tracks the
70
+ session, so it is asked rather than pumped."""
71
+ return hasattr(conn, 'is_connected')
72
+
73
+
74
+ def _state_ready(conn) -> bool:
75
+ """The switcher's state is there to read: its dump has finished and
76
+ carried a video mode, the test ``atemwire.ready.wait_ready`` uses. A
77
+ dropped session empties ``mixerstate`` while the transport re-handshakes
78
+ in place, and the new dump refills it field by field, so anything short of
79
+ this can be missing exactly the field that says a source is on air."""
80
+ if 'video-mode' not in conn.mixerstate:
81
+ return False
82
+ if _pooled(conn):
83
+ # is_ready (bmdwire >= 1.3) is False through a re-handshake, when
84
+ # is_connected stays True; the media-pool watcher asks the same.
85
+ return bool(getattr(conn, 'is_ready', conn.is_connected))
86
+ # Set at the end-of-dump marker, cleared on a drop.
87
+ return bool(getattr(conn, 'initialized', False))
88
+
89
+
90
+ class _StateWatch:
91
+ """Says whether a reading of ``is_live`` came from switcher state that was
92
+ there for the whole of it.
93
+
94
+ When a session drops, bmdwire re-handshakes it in place (since 1.4.0, 5 s
95
+ after the switcher's last packet, even while we send): the state is
96
+ emptied, then REPLACED by a new dict that the new dump fills. Whatever a
97
+ predicate answers across that is not a reading of the source. An empty
98
+ state reads as off air to one predicate; another answers "live" because it
99
+ cannot tell; the still check waits for the new dump and answers from it,
100
+ with the gap behind it unseen. So a reading counts only if the state was
101
+ ready (``_state_ready``) before and after it, and is the same dict before,
102
+ after, and at the last reading that counted. The dict test catches a drop
103
+ that came and went inside one reading.
104
+
105
+ An object that reports no session at all (no ``mixerstate``, or neither
106
+ ``initialized`` nor ``is_connected``, such as a test fake) cannot show a
107
+ gap, so its readings always count, as they did before.
108
+ """
109
+
110
+ def __init__(self, protocol):
111
+ self._protocol = protocol
112
+ self._sees = (hasattr(protocol, 'mixerstate')
113
+ and (hasattr(protocol, 'initialized')
114
+ or hasattr(protocol, 'is_connected')))
115
+ self._state = None # the dict the last reading that counted came from
116
+
117
+ def read(self, is_live: Callable[[], bool]) -> Optional[bool]:
118
+ """``is_live()`` as the cycle may use it: True or False, or None when
119
+ the reading does not count (a gap)."""
120
+ if not self._sees:
121
+ return bool(is_live())
122
+ p = self._protocol
123
+ ready_before, before = _state_ready(p), p.mixerstate
124
+ live = is_live()
125
+ after = p.mixerstate
126
+ counts = (ready_before and _state_ready(p) and after is before
127
+ and (self._state is None or after is self._state))
128
+ self._state = after if counts else None
129
+ return bool(live) if counts else None
130
+
131
+
132
+ # The cycle's phases (see wait_for_safe_cycle).
133
+ _START, _WAIT_COLD, _OBSERVE, _TRAILING = 'start', 'wait-cold', 'observe', 'trailing'
134
+
135
+
136
+ def wait_for_safe_cycle(
137
+ protocol,
138
+ is_live: Callable[[], bool],
139
+ *,
140
+ is_cancelled: Optional[Callable[[], bool]] = None,
141
+ log: Optional[Callable[[str], None]] = None,
142
+ label: str = 'source',
143
+ observation: float = TALLY_OBSERVATION_SECONDS,
144
+ timeout: float = TALLY_TIMEOUT,
145
+ ) -> bool:
146
+ """Block until ``is_live()`` shows a stable cold window, then return True.
147
+ Return False on cancel or after ``timeout`` seconds total.
148
+
149
+ The cold -> hot -> cold cycle: if the source is currently live, wait for it
150
+ to go cold; then OBSERVE for ``observation`` seconds -- if it goes live in
151
+ that window it's a rotation (a macro cycling the source on/off air), so wait
152
+ for the trailing cold; if nothing goes live it's already a stable safe gap.
153
+ This beats a naive "wait until not live", which a rotating macro can satisfy
154
+ in a transient gap that isn't actually safe to mutate in.
155
+
156
+ ``is_live`` is the per-caller liveness predicate; ``protocol.loop()`` is
157
+ pumped between polls so the mixerstate it reads stays fresh.
158
+
159
+ A gap starts the cycle again. While the session has no state (it dropped
160
+ and is re-handshaking, see ``_StateWatch``), nobody can see the source, and
161
+ a rotation can go on and off air unseen. Readings taken then are ignored,
162
+ whatever the predicate answered, and when the state is back the cycle
163
+ forgets what it saw before the gap and starts from the top with the first
164
+ reading of the new state. For each phase the gap interrupts:
165
+
166
+ - waiting for cold: nothing has been claimed yet, so starting again only
167
+ means going on from the new state.
168
+ - observing: the window has unwatched seconds in it, so a full new window
169
+ is watched, from when the state is back.
170
+ - waiting for the trailing cold: the source may have gone cold during the
171
+ gap, so how much of its off-air time is left is unknown. A first reading
172
+ off air is not taken as the trailing cold; a full window is watched.
173
+
174
+ A first reading on air after a gap is treated as on air at the start (wait
175
+ for cold, then a full window), not as a rotation: the cold before it was
176
+ not seen. The timeout counts from the first call and a restart never
177
+ extends it, so a session that keeps dropping ends in False (not safe), not
178
+ in a wait without end. A predicate that raises still ends the cycle, as
179
+ before (the still check does when the state stays away too long).
180
+ """
181
+ sink = log or (lambda _line: None)
182
+ start = time.time()
183
+ watch = _StateWatch(protocol)
184
+
185
+ def aborted() -> Optional[str]:
186
+ if is_cancelled is not None:
187
+ try:
188
+ if is_cancelled():
189
+ return 'cancelled'
190
+ except Exception:
191
+ pass
192
+ if time.time() - start >= timeout:
193
+ return f'total wait exceeded {timeout:.0f}s'
194
+ return None
195
+
196
+ phase, obs_start, gap_since = _START, 0.0, None
197
+ while True:
198
+ reason = aborted()
199
+ if reason:
200
+ sink(f'{label}: aborted ({reason})')
201
+ return False
202
+ read_at = time.time()
203
+ live = watch.read(is_live)
204
+ now = time.time()
205
+
206
+ if live is None:
207
+ # A gap. Note when it began (the reading may have waited it out).
208
+ if gap_since is None:
209
+ gap_since = read_at
210
+ sink(f'{label}: switcher state gone (session reconnecting), '
211
+ f'the source cannot be seen until it is back')
212
+ phase = _START
213
+ else:
214
+ if gap_since is not None:
215
+ sink(f'{label}: switcher state back after {now - gap_since:.1f}s, '
216
+ f'starting the cycle again')
217
+ gap_since = None
218
+ if phase == _START and live:
219
+ # Phase 1 -- currently live, wait until cold.
220
+ sink(f'{label}: currently live, waiting for cold...')
221
+ phase = _WAIT_COLD
222
+ elif phase in (_START, _WAIT_COLD) and not live:
223
+ sink(f'{label}: observing up to {int(observation)}s for live...')
224
+ phase, obs_start = _OBSERVE, now
225
+ elif phase == _OBSERVE:
226
+ # Phase 2 -- a source going live here is a rotation, not a gap.
227
+ if live:
228
+ sink(f'{label}: rotation detected, waiting for trailing cold...')
229
+ phase = _TRAILING
230
+ elif now - obs_start >= observation:
231
+ sink(f'{label}: no activity in {int(observation)}s, safe')
232
+ return True
233
+ elif phase == _TRAILING and not live:
234
+ # Phase 3 -- went live while observed; this is the trailing cold.
235
+ sink(f'{label}: cycle complete, safe')
236
+ return True
237
+
238
+ ticked_pump(protocol, idle_sleep=TALLY_POLL_INTERVAL)
@@ -10,25 +10,45 @@
10
10
 
11
11
  {% block content %}
12
12
  {% comment %} The switcher went quiet mid-session (#38): the page keeps its last
13
- state under this scrim and banner, inert, until the server says the switcher
14
- is back. They sit outside the wrapper, which goes inert, and above every
15
- overlay; the scrim dims the page without filtering it. {% endcomment %}
16
- <div x-data x-show="$store.atem.switcherAway" x-cloak
17
- class="fixed inset-0 z-[400] pointer-events-none"
13
+ state under this scrim and notice, inert, until the server says the switcher
14
+ is back. The scrim covers the page's own scroller (the <main> this page sits
15
+ in), not the window: whatever the host puts above it, its navigation, stays
16
+ usable, so the operator can just go to another page. The box is measured, not
17
+ assumed, since hosts differ in what sits above <main>; with no <main> it is the
18
+ window. The notice is centred in it. Both sit outside the wrapper, which goes
19
+ inert, and above every overlay of this page; the scrim dims the page without
20
+ filtering it. :style is an object, never a string: a string would rewrite the
21
+ style attribute and undo x-show. {% endcomment %}
22
+ <div x-data="{
23
+ box: {},
24
+ fit() {
25
+ const m = this.$el.closest('main');
26
+ const r = m ? m.getBoundingClientRect() : { top: 0, left: 0, width: innerWidth, height: innerHeight };
27
+ this.box = { top: r.top + 'px', left: r.left + 'px', width: r.width + 'px', height: r.height + 'px' };
28
+ },
29
+ init() {
30
+ const m = this.$el.closest('main');
31
+ if (m && window.ResizeObserver) new ResizeObserver(() => this.fit()).observe(m);
32
+ this.fit();
33
+ },
34
+ }"
35
+ x-show="$store.atem.switcherAway" x-cloak
36
+ x-effect="$store.atem.switcherAway && fit()"
37
+ @resize.window="fit()"
38
+ :style="box"
39
+ class="fixed z-[400] pointer-events-none flex items-center justify-center px-4"
18
40
  role="status" aria-live="polite">
19
41
  <div class="absolute inset-0 bg-base-100/50"></div>
20
42
  {% comment %} Solid raised surface, strong border: a notice, not a state. No
21
43
  alert colour: --in is the link coral, which reads as PROGRAM here. {% endcomment %}
22
- <div class="relative flex justify-center px-4 pt-4">
23
- <div class="flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-3 px-5 py-3 w-full sm:w-auto max-w-2xl bg-base-300 text-base-content border border-[color:var(--border-strong)] rounded-[14px] shadow-2xl pointer-events-auto">
24
- <span class="loading loading-spinner loading-sm text-base-content/60 shrink-0"></span>
25
- <div class="text-sm leading-snug flex-1 min-w-0">
26
- <div class="font-semibold">The switcher isn't responding. Reconnecting&hellip;</div>
27
- <div class="text-base-content/60">Controls are paused until it's back.</div>
28
- </div>
29
- {% comment %} Its own row on a phone, where it ran off the banner. {% endcomment %}
30
- <button class="btn btn-sm w-full sm:w-auto shrink-0" @click="$store.atem.disconnect()">Back to connect page</button>
44
+ <div class="relative flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-3 px-5 py-3 w-full sm:w-auto max-w-2xl bg-base-300 text-base-content border border-[color:var(--border-strong)] rounded-[14px] shadow-2xl pointer-events-auto">
45
+ <span class="loading loading-spinner loading-sm text-base-content/60 shrink-0"></span>
46
+ <div class="text-sm leading-snug flex-1 min-w-0">
47
+ <div class="font-semibold">The switcher isn't responding. Reconnecting&hellip;</div>
48
+ <div class="text-base-content/60">Controls are paused until it's back.</div>
31
49
  </div>
50
+ {% comment %} Its own row on a phone, where it ran off the banner. {% endcomment %}
51
+ <button class="btn btn-sm w-full sm:w-auto shrink-0" @click="$store.atem.disconnect()">Back to connect page</button>
32
52
  </div>
33
53
  </div>
34
54
 
@@ -73,8 +73,12 @@ REUPLOAD_SETTLE = 1.0
73
73
  STATE_READY_TIMEOUT = CONNECTION_TIMEOUT
74
74
 
75
75
  # Tally cycle-wait timings + the shared cold->hot->cold loop live in
76
- # ``content_change.tally`` so the media-pool and HyperDeck paths can't drift.
76
+ # ``atem_control.tally`` so the media-pool and HyperDeck paths can't drift.
77
+ # What "the state is there" means lives there too: the cycle and the slot
78
+ # check below must agree on it.
77
79
  from atem_control.tally import (
80
+ _pooled,
81
+ _state_ready,
78
82
  ticked_pump,
79
83
  wait_for_safe_cycle,
80
84
  TALLY_OBSERVATION_SECONDS,
@@ -211,29 +215,6 @@ class _StateNotReady(Exception):
211
215
  nothing can say the slot is off air. ``_upload_one`` refuses the item."""
212
216
 
213
217
 
214
- def _pooled(conn) -> bool:
215
- """A pooled ``ATEMConnection`` (how a host calls ``_is_slot_safe``), not
216
- the uploader's own ``AtemProtocol``: its worker thread pumps the loop and
217
- tracks the session, so it is asked rather than pumped."""
218
- return hasattr(conn, 'is_connected')
219
-
220
-
221
- def _state_ready(conn) -> bool:
222
- """The switcher's state is there to read: its dump has finished and
223
- carried a video mode, the test ``atemwire.ready.wait_ready`` uses. A
224
- dropped session empties ``mixerstate`` while the transport re-handshakes
225
- in place, and the new dump refills it field by field, so anything short of
226
- this can be missing exactly the field that says the slot is on air."""
227
- if 'video-mode' not in conn.mixerstate:
228
- return False
229
- if _pooled(conn):
230
- # is_ready (bmdwire >= 1.3) is False through a re-handshake, when
231
- # is_connected stays True; the media-pool watcher asks the same.
232
- return bool(getattr(conn, 'is_ready', conn.is_connected))
233
- # Set at the end-of-dump marker, cleared on a drop.
234
- return bool(getattr(conn, 'initialized', False))
235
-
236
-
237
218
  def _await_state(conn, timeout: float,
238
219
  is_cancelled: Optional[Callable[[], bool]] = None) -> bool:
239
220
  """Wait until the state is ready (True), or until ``timeout`` seconds pass
@@ -319,11 +300,14 @@ def _wait_for_safe_upload_window(protocol, slot_0_indexed: int,
319
300
  observation: float = TALLY_OBSERVATION_SECONDS,
320
301
  timeout: float = TALLY_TIMEOUT) -> bool:
321
302
  """Cycle-wait for tally safety before overwriting a media-pool slot. Thin
322
- wrapper over the shared ``wait_for_safe_cycle`` (content_change.tally) with
303
+ wrapper over the shared ``wait_for_safe_cycle`` (atem_control.tally) with
323
304
  the media-pool slot liveness predicate, so the cold->hot->cold loop is
324
305
  identical to the HyperDeck path. ``_StateNotReady`` from the predicate is
325
306
  not caught here: it ends the cycle at once, a refusal rather than a wait
326
- that runs out the tally timeout as if the slot had stayed live."""
307
+ that runs out the tally timeout as if the slot had stayed live. A state
308
+ that does come back in time costs the cycle a fresh start, not the
309
+ upload: the predicate's answer from the new dump is not taken as the end
310
+ of the gap's unseen seconds (see ``wait_for_safe_cycle``)."""
327
311
  slot_user = slot_0_indexed + 1
328
312
  if not _state_ready(protocol):
329
313
  source = (f"not ready (session reconnecting), waiting up to "
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webatem"
7
- version = "2.3.0"
7
+ version = "2.3.2"
8
8
  description = "Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webatem
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there
5
5
  Author: Lucas Romanenko
6
6
  License: MIT
@@ -1,141 +0,0 @@
1
- """Shared tally cycle-wait primitive.
2
-
3
- Both the still / media-pool uploader (``uploader._wait_for_safe_upload_window``)
4
- and the HyperDeck clip path (``hyperdeck_tally.wait_for_safe_window``) gate their
5
- mutations on the same cold -> hot -> cold cycle. That loop and its timings live
6
- here so the two callers can't drift; each supplies its own liveness predicate (a
7
- media-pool slot vs a deck's switcher input) and its own log label.
8
- """
9
-
10
- import time
11
- from typing import Callable, Optional
12
-
13
- from atemwire.transport import Wakeup
14
-
15
- # Tally cycle-wait timings — single source of truth for every caller.
16
- TALLY_POLL_INTERVAL = 0.05
17
- TALLY_OBSERVATION_SECONDS = 40.0
18
- TALLY_TIMEOUT = 300.0
19
-
20
-
21
- def ticked_pump(protocol, idle_sleep: float = 0.0) -> None:
22
- """One ``protocol.loop()`` that is GUARANTEED to return.
23
-
24
- A bare ``loop()`` blocks in ``thread_recv_queue.get()`` until a
25
- consumer-visible packet arrives — and on a quiet established session
26
- none ever does (keepalive pings are consumed at transport level, and
27
- their arrival keeps the UDP thread's select timeout from firing).
28
- Every deadline/cancel check "between loop() calls" is unenforceable
29
- against that block: the uploader's 0.3s flush, the 90s upload timeout
30
- and the 300s tally timeout could all hang a replica forever (found
31
- live by lock_hygiene_smoke, 2026-07-06).
32
-
33
- When the recv queue is idle, enqueue a ``Wakeup`` sentinel (the same
34
- trick ATEMConnection.send() uses) and optionally pace with
35
- ``idle_sleep``; when packets are queued, drain at full speed with no
36
- tick and no sleep (the 2026-07-02 lesson: a fixed sleep per pump
37
- throttled 6000-packet transfers from ~2s to 60s+).
38
- """
39
- idle = True
40
- try:
41
- q = protocol.transport.thread_recv_queue
42
- # "Idle" cannot be judged by qsize(): on an ESTABLISHED session the
43
- # ATEM's keepalive pings land in this queue every second, and
44
- # ``receive_packet`` swallows those control packets with a
45
- # ``continue`` — straight into another blocking ``get()``. So a
46
- # queue that is never empty (always a ping in it) meant no sentinel
47
- # was ever queued, and loop() ate ping after ping until some other
48
- # session made the switcher emit a data packet — minutes, or never.
49
- # Found live: a HyperDeck job stalled at its post-connect pump
50
- # (2026-09-09), blocked 90 s until a second session connected.
51
- # A Wakeup behind a SHORT queue costs one early return; a long queue
52
- # is a real transfer and gets no sentinel (the 2026-07-02 lesson).
53
- idle = q.qsize() <= 8
54
- if idle:
55
- q.put(Wakeup())
56
- except Exception:
57
- # No real transport (test fakes) — treat as idle so the pacing
58
- # sleep below still runs (tests drive a virtual clock through it).
59
- pass
60
- if idle and idle_sleep:
61
- time.sleep(idle_sleep)
62
- protocol.loop()
63
-
64
-
65
- def wait_for_safe_cycle(
66
- protocol,
67
- is_live: Callable[[], bool],
68
- *,
69
- is_cancelled: Optional[Callable[[], bool]] = None,
70
- log: Optional[Callable[[str], None]] = None,
71
- label: str = 'source',
72
- observation: float = TALLY_OBSERVATION_SECONDS,
73
- timeout: float = TALLY_TIMEOUT,
74
- ) -> bool:
75
- """Block until ``is_live()`` shows a stable cold window, then return True.
76
- Return False on cancel or after ``timeout`` seconds total.
77
-
78
- The cold -> hot -> cold cycle: if the source is currently live, wait for it
79
- to go cold; then OBSERVE for ``observation`` seconds -- if it goes live in
80
- that window it's a rotation (a macro cycling the source on/off air), so wait
81
- for the trailing cold; if nothing goes live it's already a stable safe gap.
82
- This beats a naive "wait until not live", which a rotating macro can satisfy
83
- in a transient gap that isn't actually safe to mutate in.
84
-
85
- ``is_live`` is the per-caller liveness predicate; ``protocol.loop()`` is
86
- pumped between polls so the mixerstate it reads stays fresh.
87
- """
88
- sink = log or (lambda _line: None)
89
- start = time.time()
90
-
91
- def aborted() -> Optional[str]:
92
- if is_cancelled is not None:
93
- try:
94
- if is_cancelled():
95
- return 'cancelled'
96
- except Exception:
97
- pass
98
- if time.time() - start >= timeout:
99
- return f'total wait exceeded {timeout:.0f}s'
100
- return None
101
-
102
- # Phase 1 -- if currently live, wait until cold.
103
- if is_live():
104
- sink(f'{label}: currently live, waiting for cold...')
105
- while is_live():
106
- reason = aborted()
107
- if reason:
108
- sink(f'{label}: aborted ({reason})')
109
- return False
110
- ticked_pump(protocol, idle_sleep=TALLY_POLL_INTERVAL)
111
-
112
- # Phase 2 -- observe; a source going live here is a rotation, not a gap.
113
- sink(f'{label}: observing up to {int(observation)}s for live...')
114
- obs_start = time.time()
115
- went_live = False
116
- while time.time() - obs_start < observation:
117
- reason = aborted()
118
- if reason:
119
- sink(f'{label}: aborted ({reason})')
120
- return False
121
- ticked_pump(protocol)
122
- if is_live():
123
- went_live = True
124
- break
125
- time.sleep(TALLY_POLL_INTERVAL)
126
-
127
- if not went_live:
128
- sink(f'{label}: no activity in {int(observation)}s, safe')
129
- return True
130
-
131
- # Phase 3 -- went live during observation; wait for the trailing cold.
132
- sink(f'{label}: rotation detected, waiting for trailing cold...')
133
- while is_live():
134
- reason = aborted()
135
- if reason:
136
- sink(f'{label}: aborted ({reason})')
137
- return False
138
- ticked_pump(protocol, idle_sleep=TALLY_POLL_INTERVAL)
139
-
140
- sink(f'{label}: cycle complete, safe')
141
- return True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes