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.
- {webatem-2.3.0 → webatem-2.3.2}/PKG-INFO +1 -1
- webatem-2.3.2/atem_control/tally.py +238 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control.html +34 -14
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/uploader.py +10 -26
- {webatem-2.3.0 → webatem-2.3.2}/pyproject.toml +1 -1
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/PKG-INFO +1 -1
- webatem-2.3.0/atem_control/tally.py +0 -141
- {webatem-2.3.0 → webatem-2.3.2}/LICENSE +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/README.md +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/activity.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/apps.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/activity.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/commands.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/consumer.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/device_api.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/logging.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/control/views.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/discovery.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/hooks.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/connection.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/hyperdeck/views.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/ip_upload_lock.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/broadcast.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/still_cache.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/views.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/media_pool/watcher.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/0001_initial.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/0002_legacy_log.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/migrations/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/models.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/netutil.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/dialog.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/export.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/profile/views.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/routing.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/sightings.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/apple-touch-icon.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/brand.css +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/favicon-32.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/favicon.ico +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-400-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-400-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-500-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-500-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-600-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-600-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-800-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/Archivo-800-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-400-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-500-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-600-latin.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/OFL-Archivo.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/OFL-IBMPlexMono.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-192.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-512.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/icon-maskable-512.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/manifest.webmanifest +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/mark.svg +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/linux-24.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/mac-22.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/mac-44.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/win-16.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/tray/win-32.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/css/atem_connect.css +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/css/atem_control.css +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/icon.png +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_connect.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_control.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_control_page.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_discovery.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_hyperdeck.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_profile.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/atem_realtime_slider.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/custom-scrollbar.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/recent_label.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/js/wa-select.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/README.md +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/alpine.min.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/webatem.css +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/storage.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/connect.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_atem_info.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_fade_to_black.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_fairlight.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_header.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_macros.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_media_pool.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_profile_modal.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_dsk.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_transition.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_settings_usk.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_slider.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_transition_controls.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_transition_style.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_fly.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_keyframe.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_mask.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templates/control/_usk_slider.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templatetags/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/templatetags/static_v.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/atem_control/urls.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/setup.cfg +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/__init__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/__main__.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/applog.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/asgi.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/context.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/launcher.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/server.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/settings.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/base.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/brand/_mark.svg +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/templates/launcher.html +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/uninstall.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/updates.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/urls.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/views.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem/websocket.py +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/SOURCES.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/dependency_links.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/entry_points.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/requires.txt +0 -0
- {webatem-2.3.0 → webatem-2.3.2}/webatem.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
14
|
-
is back.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
<div class="
|
|
26
|
-
|
|
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…</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
|
-
# ``
|
|
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`` (
|
|
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.
|
|
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,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
|
|
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
|
{webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-400-latin-ext.woff2
RENAMED
|
File without changes
|
|
File without changes
|
{webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-500-latin-ext.woff2
RENAMED
|
File without changes
|
|
File without changes
|
{webatem-2.3.0 → webatem-2.3.2}/atem_control/static/brand/fonts/IBMPlexMono-600-latin-ext.woff2
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webatem-2.3.0 → webatem-2.3.2}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|