cuemsutils 0.1.0rc11__tar.gz → 0.1.0rc15__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 (46) hide show
  1. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/PKG-INFO +2 -2
  2. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/__init__.py +2 -1
  3. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/AudioCue.py +1 -1
  4. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/FadeCue.py +17 -2
  5. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/MediaCue.py +90 -0
  6. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/VideoCue.py +41 -15
  7. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/log.py +57 -6
  8. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/HubServices.py +23 -0
  9. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/Parsers.py +6 -0
  10. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/XmlBuilder.py +29 -2
  11. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/script.xsd +37 -0
  12. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/.gitignore +0 -0
  13. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/LICENSE +0 -0
  14. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/README.md +0 -0
  15. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/create_script.py +0 -0
  16. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/ActionCue.py +0 -0
  17. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/Cue.py +0 -0
  18. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/CueList.py +0 -0
  19. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/CueOutput.py +0 -0
  20. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/CuemsScript.py +0 -0
  21. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/DmxCue.py +0 -0
  22. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/FadeProfile.py +0 -0
  23. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/cues/__init__.py +0 -0
  24. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/helpers.py +0 -0
  25. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/timeoutloop.py +0 -0
  26. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/CTimecode.py +0 -0
  27. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/CTimecodeTimer.py +0 -0
  28. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/CommunicatorServices.py +0 -0
  29. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/ConfigBase.py +0 -0
  30. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/ConfigManager.py +0 -0
  31. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/CopyMoveVersioned.py +0 -0
  32. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/FadeCalculator.py +0 -0
  33. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/SignalEngine.py +0 -0
  34. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/StringSanitizer.py +0 -0
  35. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/Uuid.py +0 -0
  36. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/tools/__init__.py +0 -0
  37. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/CMLCuemsConverter.py +0 -0
  38. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/Settings.py +0 -0
  39. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/XmlReaderWriter.py +0 -0
  40. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/__init__.py +0 -0
  41. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/network_map.xsd +0 -0
  42. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/outputs.xsd +0 -0
  43. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/project_mappings.xsd +0 -0
  44. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/project_settings.xsd +0 -0
  45. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/cuemsutils/xml/schemas/settings.xsd +0 -0
  46. {cuemsutils-0.1.0rc11 → cuemsutils-0.1.0rc15}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: cuemsutils
3
- Version: 0.1.0rc11
3
+ Version: 0.1.0rc15
4
4
  Summary: Reusable classes and methods for CueMS system
5
5
  Project-URL: Documentation, https://stagesoft.github.io/cuems-utils/
6
6
  Project-URL: Issues, https://github.com/stagesoft/cuems-utils/issues
@@ -1,4 +1,5 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Adrià (StageLab) <adria@stagelab.coop>
2
2
  #
3
3
  # SPDX-License-Identifier: GPL-3.0
4
- __version__ = "0.1.0rc11"
4
+ # SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
5
+ __version__ = "0.1.0rc15"
@@ -6,7 +6,7 @@ from ..helpers import ensure_items
6
6
  from ..log import logged, Logger
7
7
 
8
8
  REQ_ITEMS = {
9
- 'master_vol': 1 # Default to full volume (0.0-1.0 range)
9
+ 'master_vol': 100 # Default to full volume — 0-100 percent scale (cms:PercentType)
10
10
  }
11
11
 
12
12
  class AudioCue(MediaCue):
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
4
+
1
5
  from enum import Enum
2
6
 
3
7
  from ..helpers import ensure_items, format_timecode
@@ -8,12 +12,22 @@ _ZERO_TC = CTimecode('00:00:00.000')
8
12
 
9
13
 
10
14
  class FadeCurveType(Enum):
11
- """Enumeration of supported fade curve shapes."""
15
+ """Enumeration of supported fade curve shapes.
16
+
17
+ ``linear``, ``sigmoid``, ``ease_in`` and ``ease_out`` are the shapes
18
+ gradient-motiond's CurveFactory implements; the editor UI submits these.
19
+ ``exponential`` and ``logarithmic`` are legacy names kept so projects
20
+ authored by older editors still load — CurveFactory does NOT implement
21
+ them, and the frontend maps them onto ``ease_in``/``ease_out`` (the same
22
+ accelerating/decelerating shapes) on load, healing them on next save.
23
+ """
12
24
 
13
25
  linear = 'linear'
14
26
  exponential = 'exponential'
15
27
  logarithmic = 'logarithmic'
16
28
  sigmoid = 'sigmoid'
29
+ ease_in = 'ease_in'
30
+ ease_out = 'ease_out'
17
31
 
18
32
  def __str__(self):
19
33
  return self.value
@@ -125,7 +139,8 @@ class FadeCue(ActionCue):
125
139
 
126
140
  Args:
127
141
  curve_type (FadeCurveType | str): A FadeCurveType member or its
128
- string value ('linear', 'exponential', 'logarithmic', 'sigmoid').
142
+ string value ('linear', 'sigmoid', 'ease_in', 'ease_out', or
143
+ the legacy 'exponential'/'logarithmic').
129
144
 
130
145
  Raises:
131
146
  ValueError: If curve_type is not a recognised value.
@@ -1,3 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
1
4
  from typing import Tuple
2
5
 
3
6
  from .Cue import Cue
@@ -237,6 +240,93 @@ class Media(CuemsDict):
237
240
 
238
241
  regions: list[Region] = property(get_regions, set_regions)
239
242
 
243
+ # --- Stored size of the media file (869fat84r) --------------------------
244
+ #
245
+ # ``pixel_width`` / ``pixel_height`` are the original size of the file's
246
+ # first video stream, as ffprobe reports it, and ``file_size`` is the
247
+ # file's size in bytes when they were measured. ``file_md5`` (below)
248
+ # identifies the file itself. The editor stores them at
249
+ # upload so the engine does not have to probe the file when it arms a
250
+ # cue; ``file_size`` lets the engine notice a file replaced under the
251
+ # same name. All three are optional: absent means unknown.
252
+ #
253
+ # Parsing a project never calls these setters (the parsers assign keys
254
+ # raw), so the schema and MediaXmlBuilder, not these, guard what is read
255
+ # from and written to a file.
256
+
257
+ def _get_optional(self, key):
258
+ return self.get(key)
259
+
260
+ def _set_positive_int(self, key, value):
261
+ """Store *value* as a positive ``int``; ``None`` removes the key.
262
+
263
+ Raises:
264
+ ValueError: for anything that is not a positive integer or a
265
+ string of digits (``bool`` included: it is an ``int``).
266
+ """
267
+ if value is None:
268
+ self.pop(key, None)
269
+ return
270
+ if isinstance(value, bool):
271
+ raise ValueError(f"Media {key} must be a positive integer, not {value!r}")
272
+ if isinstance(value, str) and value.isdigit():
273
+ value = int(value)
274
+ if not isinstance(value, int) or value <= 0:
275
+ raise ValueError(f"Media {key} must be a positive integer, not {value!r}")
276
+ super().__setitem__(key, value)
277
+
278
+ def get_pixel_width(self):
279
+ """Width in pixels of the media's first video stream, or ``None``."""
280
+ return self._get_optional('pixel_width')
281
+
282
+ def set_pixel_width(self, pixel_width):
283
+ """Set the width in pixels; ``None`` removes it."""
284
+ self._set_positive_int('pixel_width', pixel_width)
285
+
286
+ pixel_width = property(get_pixel_width, set_pixel_width)
287
+
288
+ def get_pixel_height(self):
289
+ """Height in pixels of the media's first video stream, or ``None``."""
290
+ return self._get_optional('pixel_height')
291
+
292
+ def set_pixel_height(self, pixel_height):
293
+ """Set the height in pixels; ``None`` removes it."""
294
+ self._set_positive_int('pixel_height', pixel_height)
295
+
296
+ pixel_height = property(get_pixel_height, set_pixel_height)
297
+
298
+ def get_file_size(self):
299
+ """Size in bytes of the file the dimensions were measured on, or ``None``."""
300
+ return self._get_optional('file_size')
301
+
302
+ def set_file_size(self, file_size):
303
+ """Set the file size in bytes; ``None`` removes it."""
304
+ self._set_positive_int('file_size', file_size)
305
+
306
+ file_size = property(get_file_size, set_file_size)
307
+
308
+ def get_file_md5(self):
309
+ """The file's MD5 (32 lowercase hex digits), or ``None``. Every media
310
+ type; it comes from the upload, which already carries it (D18)."""
311
+ return self._get_optional('file_md5')
312
+
313
+ def set_file_md5(self, file_md5):
314
+ """Set the file's MD5 (any case, stored lowercase); ``None`` removes it.
315
+
316
+ Raises:
317
+ ValueError: for anything that is not 32 hex digits.
318
+ """
319
+ if file_md5 is None:
320
+ self.pop('file_md5', None)
321
+ return
322
+ if not isinstance(file_md5, str) or len(file_md5) != 32 or any(
323
+ c not in '0123456789abcdef' for c in file_md5.lower()
324
+ ):
325
+ raise ValueError(f"Media file_md5 must be 32 hex digits, not {file_md5!r}")
326
+ super().__setitem__('file_md5', file_md5.lower())
327
+
328
+ file_md5 = property(get_file_md5, set_file_md5)
329
+
240
330
  class MediaCue(Cue):
241
331
  """Base class for media-related cues (audio and video).
242
332
 
@@ -1,27 +1,32 @@
1
1
  from .MediaCue import MediaCue
2
2
  from ..tools.CTimecode import CTimecode
3
+ from ..helpers import ensure_items
3
4
  from ..log import Logger
4
5
 
6
+ REQ_ITEMS = {
7
+ 'opacity': 100 # Default to fully opaque — 0-100 percent scale (cms:PercentType)
8
+ }
5
9
 
6
10
 
7
11
  class VideoCue(MediaCue):
8
12
  """A cue for handling video playback and control.
9
-
13
+
10
14
  This class extends MediaCue to provide specific functionality for video playback,
11
15
  including frame rate handling and OSC communication for video routing.
12
16
  """
13
-
17
+
14
18
  def __init__(self, init_dict = None):
15
19
  """Initialize a VideoCue.
16
-
20
+
17
21
  Args:
18
22
  init_dict (dict, optional): Dictionary containing initialization values.
19
23
  If provided, will be used to set initial properties.
20
24
  """
21
25
  if not init_dict:
22
- super().__init__()
26
+ init_dict = REQ_ITEMS
23
27
  else:
24
- super().__init__(init_dict)
28
+ init_dict = ensure_items(init_dict, REQ_ITEMS)
29
+ super().__init__(init_dict)
25
30
 
26
31
  self._player = None
27
32
  self._osc_route = None
@@ -31,6 +36,36 @@ class VideoCue(MediaCue):
31
36
  self._start_mtc = CTimecode(framerate=25)
32
37
  self._end_mtc = CTimecode(framerate=25)
33
38
 
39
+ def get_opacity(self):
40
+ """Get the cue's configured opacity level.
41
+
42
+ Returns:
43
+ int: The opacity level, 0-100 (applied uniformly across all
44
+ layers of this cue).
45
+ """
46
+ return super().__getitem__('opacity')
47
+
48
+ def set_opacity(self, opacity):
49
+ """Set the cue's configured opacity level.
50
+
51
+ Args:
52
+ opacity (int): The new opacity level, 0-100.
53
+ """
54
+ super().__setitem__('opacity', opacity)
55
+
56
+ opacity = property(get_opacity, set_opacity)
57
+
58
+ def items(self):
59
+ """Get all items in the cue as a dictionary.
60
+
61
+ Returns:
62
+ dict_items: A view of the cue's items, with required items sorted first.
63
+ """
64
+ x = dict(super().items())
65
+ for k in sorted(REQ_ITEMS.keys()):
66
+ x[k] = self[k]
67
+ return x.items()
68
+
34
69
  def player(self, player):
35
70
  """Set the video player instance.
36
71
 
@@ -41,21 +76,12 @@ class VideoCue(MediaCue):
41
76
 
42
77
  def osc_route(self, osc_route):
43
78
  """Set the OSC route for video control.
44
-
79
+
45
80
  Args:
46
81
  osc_route (str): The OSC route to use for video control.
47
82
  """
48
83
  self._osc_route = osc_route
49
84
 
50
- def items(self):
51
- """Get all items in the cue as a dictionary.
52
-
53
- Returns:
54
- dict_items: A view of the cue's items.
55
- """
56
- x = dict(super().items())
57
- return x.items()
58
-
59
85
  def stop(self):
60
86
  """Stop the video playback.
61
87
 
@@ -1,12 +1,28 @@
1
1
  import sys
2
- from logging import getLogger, LoggerAdapter, StreamHandler, Formatter, DEBUG, INFO, ERROR, WARNING, CRITICAL
2
+ from logging import getLogger, LoggerAdapter, NullHandler, StreamHandler, Formatter, DEBUG, INFO, ERROR, WARNING, CRITICAL
3
3
  from logging.handlers import SysLogHandler
4
4
  from functools import wraps
5
5
  from os import environ
6
+ from os.path import basename
6
7
  import inspect
7
8
 
8
9
  cuemsFormatter = Formatter('[%(asctime)s][%(levelname)s] \tFormitGo (PID: %(process)d)-(%(threadName)-9s)-(%(name)s:%(funcName)s:%(caller)s)> %(message)s')
9
10
 
11
+ # Third-party libraries sometimes call the module-level logging.info()/
12
+ # warning() helpers at import time (pyossia does, from cuemsengine.osc).
13
+ # Those helpers run logging.basicConfig() whenever the root logger has no
14
+ # handlers, which attaches a StreamHandler(stderr) using logging.BASIC_FORMAT
15
+ # to root. Since the loggers built below propagate, every CUEMS record then
16
+ # gets re-emitted through that handler as a second, differently formatted
17
+ # line — on top of the stdout and syslog copies. Under systemd all of them
18
+ # land in the same journal, so each message was recorded three times.
19
+ #
20
+ # Seeding root with a NullHandler makes the implicit basicConfig() a no-op:
21
+ # it returns early once root has any handler. Propagation is deliberately
22
+ # left enabled — pytest's caplog fixture captures through a root handler and
23
+ # depends on records reaching it.
24
+ getLogger().addHandler(NullHandler())
25
+
10
26
  # Cache for module-specific loggers to avoid duplicate handlers
11
27
  _logger_cache = {}
12
28
 
@@ -41,22 +57,42 @@ class CuemsLoggerAdapter(LoggerAdapter):
41
57
  kwargs['extra'] = extra
42
58
  return msg, kwargs
43
59
 
60
+ def _syslog_ident():
61
+ """
62
+ Tag to prefix onto /dev/log datagrams, e.g. 'controller-engine: '.
63
+
64
+ journald derives SYSLOG_IDENTIFIER by parsing a leading 'TAG:' or
65
+ 'TAG[PID]:' off the datagram. Without one the entry carries no
66
+ identifier and journalctl falls back to _COMM, which the kernel
67
+ truncates to 15 characters — 'controller-engine' rendered as
68
+ 'controller-engi', looking like a second, unrelated process.
69
+
70
+ The PID is deliberately left out: journald fills _PID from the socket
71
+ credentials, so it is always right, whereas a tag built once at handler
72
+ creation would go stale across a fork.
73
+ """
74
+ name = basename(sys.argv[0] or '') or 'cuems'
75
+ return f'{name}: '
76
+
44
77
  def main_logger(module_name = None, with_syslog = True, with_stdout = True):
45
78
  """
46
79
  Create a root logger with a custom formatter.
47
-
80
+
48
81
  Args:
49
82
  module_name: Name of the module to create logger for. Defaults to __name__ if None.
50
83
  with_syslog: Whether to add syslog handler.
51
84
  with_stdout: Whether to add stdout handler.
85
+
86
+ Under systemd both handlers terminate in the same journal, so only the
87
+ syslog one is attached there — see the comment on the stdout branch.
52
88
  """
53
89
  if module_name is None:
54
90
  module_name = __name__
55
-
91
+
56
92
  # Return cached logger if it exists
57
93
  if module_name in _logger_cache:
58
94
  return _logger_cache[module_name]
59
-
95
+
60
96
  logger = getLogger(module_name)
61
97
  try:
62
98
  log_level = log_level_to_obj(environ['CUEMS_LOG_LEVEL'].upper())
@@ -64,16 +100,31 @@ def main_logger(module_name = None, with_syslog = True, with_stdout = True):
64
100
  log_level = DEBUG
65
101
  logger.setLevel(log_level)
66
102
 
67
- if with_stdout:
103
+ # Under systemd, stdout and /dev/log both terminate in journald, so
104
+ # attaching both handlers records every message twice. Keep the syslog
105
+ # one: it is the only copy that carries the record's real priority.
106
+ # systemd stamps every stdout line PRIORITY=6 (info) regardless of the
107
+ # Python level, so a journalctl -p filter — which is what
108
+ # `cuems-logs -l/--level` and `-e/--errors` are built on — cannot tell a
109
+ # DEBUG line from an ERROR one on the stdout copy.
110
+ #
111
+ # JOURNAL_STREAM is set by systemd exactly when stdout/stderr are wired
112
+ # to the journal. When it is absent (interactive runs, pytest, a
113
+ # container logging elsewhere) stdout is attached as before, so running
114
+ # a component by hand still prints to the terminal.
115
+ journald_stdout = with_syslog and 'JOURNAL_STREAM' in environ
116
+
117
+ if with_stdout and not journald_stdout:
68
118
  sh = StreamHandler(sys.stdout)
69
119
  sh.setFormatter(cuemsFormatter)
70
120
  logger.addHandler(sh)
71
-
121
+
72
122
  if with_syslog:
73
123
  syslog_handler = SysLogHandler(
74
124
  address = '/dev/log', facility = 'local0'
75
125
  )
76
126
  syslog_handler.setFormatter(cuemsFormatter)
127
+ syslog_handler.ident = _syslog_ident()
77
128
  logger.addHandler(syslog_handler)
78
129
 
79
130
  logger_adapter = CuemsLoggerAdapter(logger, {})
@@ -429,6 +429,8 @@ class NngBusHub(HubService):
429
429
  The loop runs until cancelled or an unhandled exception occurs.
430
430
  """
431
431
  while await asyncio.sleep(0, result=True):
432
+ if self._closing:
433
+ return
432
434
  try:
433
435
  pynng_message = await self.connection.arecv_msg()
434
436
 
@@ -463,6 +465,17 @@ class NngBusHub(HubService):
463
465
 
464
466
  except pynng_exceptions.Timeout:
465
467
  pass # Timeout is expected during polling
468
+ except pynng_exceptions.Closed:
469
+ # The socket is gone — either an orderly teardown, or pynng's
470
+ # atexit hook already ran nng_fini() and freed NNG's global
471
+ # state. Retrying would call nng_recv_aio() on that freed
472
+ # state, which aborts the whole process with
473
+ # "panic: pthread_mutex_lock: Invalid argument". Stop for good.
474
+ self._closing = True
475
+ Logger.info(
476
+ f"NNG {self.mode.value} receiver: socket closed, stopping loop"
477
+ )
478
+ return
466
479
  except Exception as e:
467
480
  Logger.error(f"Error in receiver handler: {e}, type: {type(e)}")
468
481
 
@@ -528,6 +541,8 @@ class NngBusHub(HubService):
528
541
  Logger.info(f"Node connected, sender ready ({len(self.active_connections)} connections)")
529
542
 
530
543
  while await asyncio.sleep(0, result=True):
544
+ if self._closing:
545
+ return
531
546
  try:
532
547
  message = await self.outgoing.get()
533
548
 
@@ -544,6 +559,14 @@ class NngBusHub(HubService):
544
559
  Logger.debug(f"[SEND #{self._messages_sent_count}] {self.mode.value} sending: type={msg_type}")
545
560
 
546
561
  await self.connection.asend(message.encode())
562
+ except pynng_exceptions.Closed:
563
+ # Same rule as the receiver: never touch NNG again once the
564
+ # socket is closed (see _receiver_handler).
565
+ self._closing = True
566
+ Logger.info(
567
+ f"NNG {self.mode.value} sender: socket closed, stopping loop"
568
+ )
569
+ return
547
570
  except Exception as e:
548
571
  Logger.error(f"Error in send handler: {e}, type: {type(e)}")
549
572
 
@@ -1,3 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
1
4
  from ..cues import *
2
5
  from ..cues.FadeProfile import FadeFunctionParameter, FadeProfile
3
6
  from ..cues.MediaCue import Media, Region
@@ -33,6 +36,9 @@ STRING_TYPED_KEYS = frozenset({
33
36
  'name', 'description', 'file_name',
34
37
  # defensive -- see above
35
38
  'output_name', 'parameter_name', 'icon', 'color', 'unix_name',
39
+ # an MD5 is hex: about one in a million is all digits (-> int) or digits
40
+ # with one 'e' (-> float), and would no longer validate (869fat84r D18)
41
+ 'file_md5',
36
42
  })
37
43
 
38
44
  class GenericDict(dict):
@@ -1,3 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
1
4
  from enum import Enum
2
5
  from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace
3
6
 
@@ -170,10 +173,34 @@ class GenericComplexSubObjectXmlBuilder(CuemsScriptXmlBuilder):
170
173
  class CTimecodeXmlBuilder(GenericSimpleSubObjectXmlBuilder):
171
174
  pass
172
175
 
176
+ #: ``MediaType``'s element order in script.xsd. The writer follows it, not the
177
+ #: dict's order: the editor appends the stored dimensions after whatever the
178
+ #: frontend sent, and the parsers assign keys in arrival order (869fat84r).
179
+ MEDIA_ELEMENT_ORDER = (
180
+ 'file_name', 'id', 'duration', 'regions',
181
+ 'pixel_width', 'pixel_height', 'file_size', 'file_md5',
182
+ )
183
+
184
+ #: Optional MediaType elements: a ``None`` value writes no element at all
185
+ #: (an empty ``<pixel_width/>`` would fail ``xs:positiveInteger``).
186
+ MEDIA_OPTIONAL_ELEMENTS = frozenset({'pixel_width', 'pixel_height', 'file_size', 'file_md5'})
187
+
188
+
189
+ def _media_items_in_schema_order(media):
190
+ """``media``'s items, known keys in schema order, then any others in
191
+ arrival order (so the validator, not this writer, rejects them)."""
192
+ known = [(k, media[k]) for k in MEDIA_ELEMENT_ORDER if k in media]
193
+ other = [(k, v) for k, v in media.items() if k not in MEDIA_ELEMENT_ORDER]
194
+ return [
195
+ (k, v) for k, v in known + other
196
+ if not (v is None and k in MEDIA_OPTIONAL_ELEMENTS)
197
+ ]
198
+
199
+
173
200
  class MediaXmlBuilder(GenericComplexSubObjectXmlBuilder):
174
201
  def build(self):
175
202
  if isinstance(self._object, dict):
176
- for key, value in self._object.items():
203
+ for key, value in _media_items_in_schema_order(self._object):
177
204
  if isinstance(value, VALUE_TYPES):
178
205
  cue_subelement = SubElement(self.xml_tree, key)
179
206
  cue_subelement.text = str(value)
@@ -334,7 +361,7 @@ class MediaCueXmlBuilder(GenericCueXmlBuilder):
334
361
  builder_class(value, xml_tree=cue_subelement).build()
335
362
  cls_name = type(self._object).__name__
336
363
  if key == 'master_vol' or (
337
- key == 'outputs' and cls_name == 'VideoCue'
364
+ key == 'opacity' and cls_name == 'VideoCue'
338
365
  ):
339
366
  fps = self._object.get('fade_profiles')
340
367
  if fps:
@@ -1,4 +1,9 @@
1
1
  <?xml version="1.1" encoding="utf-8"?>
2
+ <!--
3
+ SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
4
+ SPDX-License-Identifier: GPL-3.0-or-later
5
+ SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
6
+ -->
2
7
  <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:vc="http://www.w3.org/2007/XMLSchema-versioning" vc:minVersion="1.1" targetNamespace="https://stagelab.coop/cuems/" xmlns:cms="https://stagelab.coop/cuems/">
3
8
  <!-- The main capsule to make it a whole, named CueMs -->
4
9
  <!-- Our main object the script-->
@@ -181,9 +186,31 @@
181
186
  <xs:element name="id" type="cms:TargetType" />
182
187
  <xs:element name="duration" type="cms:TimecodeType" />
183
188
  <xs:element name="regions" type="cms:RegionsType" />
189
+ <!-- Stored size of the media file (869fat84r), measured by the editor
190
+ at upload so the engine does not probe the file when it arms a cue.
191
+ pixel_width / pixel_height: the ORIGINAL size of the file's first
192
+ video stream, as ffprobe reports it; not the layer's size on screen
193
+ (that would get its own names) and unrelated to CanvasRegionType's
194
+ unit-float width / height. file_size: the file's size in bytes when
195
+ they were measured; the engine compares it with its own copy to
196
+ notice a file replaced under the same name. Optional: absent means
197
+ unknown, and the engine then probes as before. The pixel size is
198
+ for VideoCues only; file_size and file_md5 for every media type.
199
+ file_md5: the file's MD5 (lowercase hex), from the upload, which
200
+ already carries it; identifies the file for integrity checks. -->
201
+ <xs:element name="pixel_width" type="xs:positiveInteger" minOccurs="0" />
202
+ <xs:element name="pixel_height" type="xs:positiveInteger" minOccurs="0" />
203
+ <xs:element name="file_size" type="xs:positiveInteger" minOccurs="0" />
204
+ <xs:element name="file_md5" type="cms:Md5Type" minOccurs="0" />
184
205
  </xs:sequence>
185
206
  </xs:complexType>
186
207
 
208
+ <xs:simpleType name="Md5Type">
209
+ <xs:restriction base="xs:string">
210
+ <xs:pattern value="[0-9a-f]{32}" />
211
+ </xs:restriction>
212
+ </xs:simpleType>
213
+
187
214
  <xs:complexType name="OutputsType">
188
215
  <xs:choice minOccurs="0" maxOccurs="unbounded">
189
216
  <xs:element name="AudioCueOutput" type="cms:AudioCueOutputsType" />
@@ -251,12 +278,18 @@
251
278
  </xs:restriction>
252
279
  </xs:simpleType>
253
280
 
281
+ <!-- linear/sigmoid/ease_in/ease_out are the curves gradient-motiond's
282
+ CurveFactory implements (the editor UI submits only these);
283
+ exponential/logarithmic are legacy names kept so old projects still
284
+ load — the frontend heals them to ease_in/ease_out on next save. -->
254
285
  <xs:simpleType name="FadeCurveType">
255
286
  <xs:restriction base="xs:string">
256
287
  <xs:enumeration value="linear" />
257
288
  <xs:enumeration value="exponential" />
258
289
  <xs:enumeration value="logarithmic" />
259
290
  <xs:enumeration value="sigmoid" />
291
+ <xs:enumeration value="ease_in" />
292
+ <xs:enumeration value="ease_out" />
260
293
  </xs:restriction>
261
294
  </xs:simpleType>
262
295
 
@@ -312,6 +345,10 @@
312
345
  <xs:complexContent>
313
346
  <xs:extension base="cms:MediaCueType">
314
347
  <xs:sequence>
348
+ <!-- Optional (unlike AudioCue's master_vol) to stay backward-compatible
349
+ with existing project files authored before this field existed;
350
+ absent -> Python-side REQ_ITEMS default of 100 (fully opaque). -->
351
+ <xs:element name="opacity" type="cms:PercentType" minOccurs="0" />
315
352
  <xs:element name="fade_profiles" type="cms:FadeProfilesWrapperType" minOccurs="0" />
316
353
  </xs:sequence>
317
354
  </xs:extension>
File without changes
File without changes