Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,33 @@ All planning docs, implementation plans, agent prompts / reusable AI prompt temp

- **The shared venv `/usr/lib/cuems` is one-way**: it has `include-system-site-packages = true`, so the venv sees system packages but the system `/usr/bin/python3` **cannot** import `cuemsutils`. Any tool whose entry points live in `/usr/bin` (dh-python3) must not `import cuemsutils` — reimplement with stdlib. (See the portable-.deb notes in the cuems-thermalmon CLAUDE.md.)
- Many packages populate that shared venv (`cuemsutils`, pynng, lxml, **websockets** from cuems-utils; **pythonosc** from cuems-engine; plus editor/nodeconf/midi-connector). A `.deb` must not bundle anything another package already ships, or `dpkg -i` aborts on a file-overwrite conflict. lxml is bundled at 5.3.0 (CVE-2026-41066 fixed in 6.1.0; audited across all components — zero parser call sites, reachable risk nil).

### Building the `.deb` — verify the venv interpreter before shipping

Built from the `debian/bookworm` branch (merge `main` in, add a `debian/changelog` entry, then `dpkg-buildpackage -us -uc -b`; artifacts land in the parent dir).

`debian/rules` pins `dh_virtualenv --python /usr/bin/python3` **by absolute path on purpose**. It used to say `--python python3`, which resolves through `PATH` — so building on a dev box with **pyenv** active baked the builder's private interpreter into the package:

```
pyvenv.cfg home = /home/<user>/.pyenv/versions/3.11.2/bin
bin/python -> /home/<user>/.pyenv/versions/3.11.2/bin/python3.11
```

On every target host that symlink is broken, so `/usr/lib/cuems/bin/python` does not exist and **nothing in the shared venv can start** — every CUEMS Python component at once. There is no build-time error; it only surfaces after `dpkg -i` on a node, and even then the running process survives until the next restart. Always check before shipping:

```bash
dpkg-deb --fsys-tarfile <deb> | tar -xO ./usr/lib/cuems/pyvenv.cfg | grep '^home'
# must be: home = /usr/bin (and bin/python -> ../../../bin/python3, relative)
```

### "Editable install" on a host is a no-op unless you remove the packaged copy

The `.deb` installs `cuemsutils/` into `/usr/lib/cuems/lib/python3.11/site-packages/`, which comes **before** any `.pth`-appended path in `sys.path`. So a `_cuemsutils.pth` pointing at `/home/stagelab/src/cuems-utils/src` is silently ignored while the package is installed — which is why past "editable" deploys did nothing and people resorted to rsync-ing source over site-packages (leaving mismatched source/dist-info, e.g. rc13 files under an rc12 `dist-info`). To get a real editable install:

```bash
/usr/lib/cuems/bin/pip uninstall -y cuemsutils
rm -rf /usr/lib/cuems/lib/python3.11/site-packages/cuemsutils # pip leaves non-RECORD strays
/usr/lib/cuems/bin/pip install -e /home/stagelab/src/cuems-utils --no-deps
```

Note `pip install -e` needs network for the build backend, so it is not an option on an offline node — install the `.deb` there instead. And an `apt install --reinstall cuems-utils` restores the packaged copy and silently re-shadows the editable tree.
9 changes: 7 additions & 2 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
cuems-utils (0.1.0rc15) UNRELEASED; urgency=medium
cuems-utils (0.1.0rc15) unstable; urgency=medium

* Merge main into debian/bookworm (0.1.0rc15, 869fat84r): MediaType
gains optional pixel_width / pixel_height (VideoCue), file_size and
file_md5 (every media); the builder writes them in schema order and the
parser keeps file_md5 a string. Same code as cuemsutils 0.1.0rc15 on
PyPI (tag v0.1.0rc15).
* Replaces: cuems-engine (<< 0.1.0rc6-2~). Engine debs up to 0.1.0rc6-1
also ship /usr/lib/cuems/bin/cffi-gen-src (cffi's console script), so
upgrading cuems-utils on a box with any of them failed with "trying to
overwrite" unless forced. cuems-engine 0.1.0rc6-2 stops shipping it.

-- Ion Reguera <ion@stagelab.coop> Tue, 06 Oct 2026 21:00:00 +0200
-- Ion Reguera <ion@stagelab.coop> Tue, 06 Oct 2026 20:18:57 +0200

cuems-utils (0.1.0rc14) unstable; urgency=medium

Expand Down
3 changes: 2 additions & 1 deletion src/cuemsutils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# SPDX-FileCopyrightText: 2025-present Adrià (StageLab) <adria@stagelab.coop>
#
# SPDX-License-Identifier: GPL-3.0
__version__ = "0.1.0rc14"
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
__version__ = "0.1.0rc15"
19 changes: 17 additions & 2 deletions src/cuemsutils/cues/FadeCue.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>

from enum import Enum

from ..helpers import ensure_items, format_timecode
Expand All @@ -8,12 +12,22 @@


class FadeCurveType(Enum):
"""Enumeration of supported fade curve shapes."""
"""Enumeration of supported fade curve shapes.

``linear``, ``sigmoid``, ``ease_in`` and ``ease_out`` are the shapes
gradient-motiond's CurveFactory implements; the editor UI submits these.
``exponential`` and ``logarithmic`` are legacy names kept so projects
authored by older editors still load — CurveFactory does NOT implement
them, and the frontend maps them onto ``ease_in``/``ease_out`` (the same
accelerating/decelerating shapes) on load, healing them on next save.
"""

linear = 'linear'
exponential = 'exponential'
logarithmic = 'logarithmic'
sigmoid = 'sigmoid'
ease_in = 'ease_in'
ease_out = 'ease_out'

def __str__(self):
return self.value
Expand Down Expand Up @@ -125,7 +139,8 @@ def set_curve_type(self, curve_type) -> None:

Args:
curve_type (FadeCurveType | str): A FadeCurveType member or its
string value ('linear', 'exponential', 'logarithmic', 'sigmoid').
string value ('linear', 'sigmoid', 'ease_in', 'ease_out', or
the legacy 'exponential'/'logarithmic').

Raises:
ValueError: If curve_type is not a recognised value.
Expand Down
90 changes: 90 additions & 0 deletions src/cuemsutils/cues/MediaCue.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
from typing import Tuple

from .Cue import Cue
Expand Down Expand Up @@ -237,6 +240,93 @@ def set_regions(self, regions):

regions: list[Region] = property(get_regions, set_regions)

# --- Stored size of the media file (869fat84r) --------------------------
#
# ``pixel_width`` / ``pixel_height`` are the original size of the file's
# first video stream, as ffprobe reports it, and ``file_size`` is the
# file's size in bytes when they were measured. ``file_md5`` (below)
# identifies the file itself. The editor stores them at
# upload so the engine does not have to probe the file when it arms a
# cue; ``file_size`` lets the engine notice a file replaced under the
# same name. All three are optional: absent means unknown.
#
# Parsing a project never calls these setters (the parsers assign keys
# raw), so the schema and MediaXmlBuilder, not these, guard what is read
# from and written to a file.

def _get_optional(self, key):
return self.get(key)

def _set_positive_int(self, key, value):
"""Store *value* as a positive ``int``; ``None`` removes the key.

Raises:
ValueError: for anything that is not a positive integer or a
string of digits (``bool`` included: it is an ``int``).
"""
if value is None:
self.pop(key, None)
return
if isinstance(value, bool):
raise ValueError(f"Media {key} must be a positive integer, not {value!r}")
if isinstance(value, str) and value.isdigit():
value = int(value)
if not isinstance(value, int) or value <= 0:
raise ValueError(f"Media {key} must be a positive integer, not {value!r}")
super().__setitem__(key, value)

def get_pixel_width(self):
"""Width in pixels of the media's first video stream, or ``None``."""
return self._get_optional('pixel_width')

def set_pixel_width(self, pixel_width):
"""Set the width in pixels; ``None`` removes it."""
self._set_positive_int('pixel_width', pixel_width)

pixel_width = property(get_pixel_width, set_pixel_width)

def get_pixel_height(self):
"""Height in pixels of the media's first video stream, or ``None``."""
return self._get_optional('pixel_height')

def set_pixel_height(self, pixel_height):
"""Set the height in pixels; ``None`` removes it."""
self._set_positive_int('pixel_height', pixel_height)

pixel_height = property(get_pixel_height, set_pixel_height)

def get_file_size(self):
"""Size in bytes of the file the dimensions were measured on, or ``None``."""
return self._get_optional('file_size')

def set_file_size(self, file_size):
"""Set the file size in bytes; ``None`` removes it."""
self._set_positive_int('file_size', file_size)

file_size = property(get_file_size, set_file_size)

def get_file_md5(self):
"""The file's MD5 (32 lowercase hex digits), or ``None``. Every media
type; it comes from the upload, which already carries it (D18)."""
return self._get_optional('file_md5')

def set_file_md5(self, file_md5):
"""Set the file's MD5 (any case, stored lowercase); ``None`` removes it.

Raises:
ValueError: for anything that is not 32 hex digits.
"""
if file_md5 is None:
self.pop('file_md5', None)
return
if not isinstance(file_md5, str) or len(file_md5) != 32 or any(
c not in '0123456789abcdef' for c in file_md5.lower()
):
raise ValueError(f"Media file_md5 must be 32 hex digits, not {file_md5!r}")
super().__setitem__('file_md5', file_md5.lower())

file_md5 = property(get_file_md5, set_file_md5)

class MediaCue(Cue):
"""Base class for media-related cues (audio and video).

Expand Down
6 changes: 6 additions & 0 deletions src/cuemsutils/xml/Parsers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
from ..cues import *
from ..cues.FadeProfile import FadeFunctionParameter, FadeProfile
from ..cues.MediaCue import Media, Region
Expand Down Expand Up @@ -33,6 +36,9 @@
'name', 'description', 'file_name',
# defensive -- see above
'output_name', 'parameter_name', 'icon', 'color', 'unix_name',
# an MD5 is hex: about one in a million is all digits (-> int) or digits
# with one 'e' (-> float), and would no longer validate (869fat84r D18)
'file_md5',
})

class GenericDict(dict):
Expand Down
29 changes: 28 additions & 1 deletion src/cuemsutils/xml/XmlBuilder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
from enum import Enum
from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace

Expand Down Expand Up @@ -170,10 +173,34 @@ def recurser(self, group, xml_tree):
class CTimecodeXmlBuilder(GenericSimpleSubObjectXmlBuilder):
pass

#: ``MediaType``'s element order in script.xsd. The writer follows it, not the
#: dict's order: the editor appends the stored dimensions after whatever the
#: frontend sent, and the parsers assign keys in arrival order (869fat84r).
MEDIA_ELEMENT_ORDER = (
'file_name', 'id', 'duration', 'regions',
'pixel_width', 'pixel_height', 'file_size', 'file_md5',
)

#: Optional MediaType elements: a ``None`` value writes no element at all
#: (an empty ``<pixel_width/>`` would fail ``xs:positiveInteger``).
MEDIA_OPTIONAL_ELEMENTS = frozenset({'pixel_width', 'pixel_height', 'file_size', 'file_md5'})


def _media_items_in_schema_order(media):
"""``media``'s items, known keys in schema order, then any others in
arrival order (so the validator, not this writer, rejects them)."""
known = [(k, media[k]) for k in MEDIA_ELEMENT_ORDER if k in media]
other = [(k, v) for k, v in media.items() if k not in MEDIA_ELEMENT_ORDER]
return [
(k, v) for k, v in known + other
if not (v is None and k in MEDIA_OPTIONAL_ELEMENTS)
]


class MediaXmlBuilder(GenericComplexSubObjectXmlBuilder):
def build(self):
if isinstance(self._object, dict):
for key, value in self._object.items():
for key, value in _media_items_in_schema_order(self._object):
if isinstance(value, VALUE_TYPES):
cue_subelement = SubElement(self.xml_tree, key)
cue_subelement.text = str(value)
Expand Down
33 changes: 33 additions & 0 deletions src/cuemsutils/xml/schemas/script.xsd
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<?xml version="1.1" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
-->
<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/">
<!-- The main capsule to make it a whole, named CueMs -->
<!-- Our main object the script-->
Expand Down Expand Up @@ -181,9 +186,31 @@
<xs:element name="id" type="cms:TargetType" />
<xs:element name="duration" type="cms:TimecodeType" />
<xs:element name="regions" type="cms:RegionsType" />
<!-- Stored size of the media file (869fat84r), measured by the editor
at upload so the engine does not probe the file when it arms a cue.
pixel_width / pixel_height: the ORIGINAL size of the file's first
video stream, as ffprobe reports it; not the layer's size on screen
(that would get its own names) and unrelated to CanvasRegionType's
unit-float width / height. file_size: the file's size in bytes when
they were measured; the engine compares it with its own copy to
notice a file replaced under the same name. Optional: absent means
unknown, and the engine then probes as before. The pixel size is
for VideoCues only; file_size and file_md5 for every media type.
file_md5: the file's MD5 (lowercase hex), from the upload, which
already carries it; identifies the file for integrity checks. -->
<xs:element name="pixel_width" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="pixel_height" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="file_size" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="file_md5" type="cms:Md5Type" minOccurs="0" />
</xs:sequence>
</xs:complexType>

<xs:simpleType name="Md5Type">
<xs:restriction base="xs:string">
<xs:pattern value="[0-9a-f]{32}" />
</xs:restriction>
</xs:simpleType>

<xs:complexType name="OutputsType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="AudioCueOutput" type="cms:AudioCueOutputsType" />
Expand Down Expand Up @@ -251,12 +278,18 @@
</xs:restriction>
</xs:simpleType>

<!-- linear/sigmoid/ease_in/ease_out are the curves gradient-motiond's
CurveFactory implements (the editor UI submits only these);
exponential/logarithmic are legacy names kept so old projects still
load — the frontend heals them to ease_in/ease_out on next save. -->
<xs:simpleType name="FadeCurveType">
<xs:restriction base="xs:string">
<xs:enumeration value="linear" />
<xs:enumeration value="exponential" />
<xs:enumeration value="logarithmic" />
<xs:enumeration value="sigmoid" />
<xs:enumeration value="ease_in" />
<xs:enumeration value="ease_out" />
</xs:restriction>
</xs:simpleType>

Expand Down
38 changes: 36 additions & 2 deletions tests/test_fade_cue.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>

"""Unit and integration tests for FadeCue."""

from os import path
Expand Down Expand Up @@ -45,11 +49,11 @@ def test_fade_cue_explicit_construction():
assert fc.target_value == 75


def _make_script_with_fade_cue():
def _make_script_with_fade_cue(curve_type=FadeCurveType.linear):
"""Helper: build a minimal CuemsScript containing one FadeCue."""
fc = FadeCue({
'action_target': TARGET_UUID,
'curve_type': FadeCurveType.linear,
'curve_type': curve_type,
'duration': '00:00:05.000',
'target_value': 50,
})
Expand Down Expand Up @@ -128,6 +132,36 @@ def test_invalid_curve_type_raises():
fc.curve_type = 'triangle'


@pytest.mark.parametrize('curve', ['linear', 'sigmoid', 'ease_in', 'ease_out'])
def test_engine_native_curve_type_round_trip(curve):
"""Every curve name the editor UI submits survives the XML round-trip.

The frontend sends gradient-motiond's own CurveFactory names (its
"exponential"/"logarithmic" labels submit ease_in/ease_out), so both the
Python enum and the script.xsd enumeration must accept all four.
"""
script, _ = _make_script_with_fade_cue(FadeCurveType(curve))
writer = XmlReaderWriter(schema_name='script', xmlfile=TMP_FILE)
writer.write_from_object(script)
assert writer.validate() is None

reader = XmlReaderWriter(schema_name='script', xmlfile=TMP_FILE)
loaded_fc = reader.read_to_objects().cuelist.contents[0]
assert loaded_fc.curve_type == FadeCurveType(curve)


@pytest.mark.parametrize('curve', ['exponential', 'logarithmic'])
def test_legacy_curve_type_still_accepted(curve):
"""Legacy curve names keep loading so old projects don't break.

gradient-motiond never implemented these; the frontend heals them to
ease_in/ease_out on load, but utils must keep accepting them until then.
"""
fc = FadeCue()
fc.curve_type = curve
assert fc.curve_type == FadeCurveType(curve)


def test_zero_duration_raises():
"""T019: Setting duration to zero raises ValueError."""
fc = FadeCue()
Expand Down
Loading
Loading