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
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"
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
22 changes: 22 additions & 0 deletions src/cuemsutils/xml/schemas/script.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,31 @@ SPDX-FileContributor: Ion Reguera <ion@stagelab.coop>
<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
Loading
Loading