#!/usr/bin/env python
"""CDL Convert Collection Module
This module contains the ColorCollection class, which serves as a unified
container for both ColorCorrectionCollection and ColorDecisionList formats,
providing seamless conversion between ASC CDL collection types.
Classes:
ColorCollection: Collection container for ColorCorrections and
ColorDecisions with support for both CCC and CDL export formats.
## License
The MIT License (MIT)
cdl_convert
Copyright (c) 2015-2025 Sean Wallitsch
http://github.com/shidarin/cdl_convert/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
# ==============================================================================
# IMPORTS
# ==============================================================================
# Standard Imports
import builtins
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
# cdl_convert imports
from cdl_convert import config
from cdl_convert.base import AscColorSpaceBase, AscDescBase, AscXMLBase
from cdl_convert.correction import ColorCorrection
from cdl_convert.decision import ColorCorrectionRef, ColorDecision
from cdl_convert.exceptions import ValidationError
# ==============================================================================
# EXPORTS
# ==============================================================================
__all__ = [
"ColorCollection",
]
# ==============================================================================
# CLASSES
# ==============================================================================
[docs]
class ColorCollection(AscDescBase, AscColorSpaceBase, AscXMLBase): # pylint: disable=R0902,R0904
"""Container class for ColorDecisionLists and ColorCorrectionCollections.
ColorCollection stores child ColorCorrection and ColorDecision objects and
can export them as either ColorCorrectionCollection (.ccc) or
ColorDecisionList (.cdl) XML formats. It inherits description, colorspace,
and XML functionality from base classes.
Example:
>>> collection = ColorCollection()
>>> cc = ColorCorrection("shot_001")
>>> collection.append_child(cc)
>>> collection.set_to_ccc()
>>> print(collection.type) # 'ccc'
"""
members: list["ColorCollection"] = []
"""All ColorCollection instances are tracked in this list. Used for
generating default filenames when no input file is set."""
[docs]
def __init__(self, input_file: str | Path | None = None) -> None:
super().__init__()
self._color_corrections: list[ColorCorrection] = []
self._color_decisions: list[ColorDecision] = []
self._file_in: Path | None = (
Path(input_file).resolve() if input_file else None
)
self._file_out: Path | None = None
self._type: str = "ccc"
self._xmlns: str = "urn:ASC:CDL:v1.01"
ColorCollection.members.append(self)
# Properties ==============================================================
@property
def all_children(self) -> list[ColorCorrection | ColorDecision]:
"""Return combined list of ColorCorrection and ColorDecision."""
return self.color_corrections + self.color_decisions
@property
def color_corrections(self) -> list[ColorCorrection]:
"""Return list of ColorCorrection children."""
return self._color_corrections
@color_corrections.setter
def color_corrections(
self,
values: None
| ColorCorrection
| list[ColorCorrection]
| tuple[ColorCorrection, ...]
| set,
) -> None:
"""Set color_corrections list, ensuring all items are ColorCorrection instances."""
self._color_corrections = self._list_setter(
"color_corrections", ColorCorrection, values
)
@property
def color_decisions(self) -> list[ColorDecision]:
"""Return list of ColorDecision children."""
return self._color_decisions
@color_decisions.setter
def color_decisions(
self,
values: None
| ColorDecision
| list[ColorDecision]
| tuple[ColorDecision, ...]
| set,
) -> None:
"""Set color_decisions list, ensuring all items are ColorDecision."""
self._color_decisions = self._list_setter(
"color_decisions", ColorDecision, values
)
@property
def file_in(self) -> Path | None:
"""Return absolute path to the input file."""
return self._file_in
@file_in.setter
def file_in(self, value: str | Path | None) -> None:
"""Set file_in to absolute path of the provided file."""
if value:
self._file_in = Path(value).resolve()
@property
def file_out(self) -> Path | None:
"""Return output file path for writing this collection."""
return self._file_out
@property
def id_list(self) -> list[str]:
"""Return sorted list of IDs from all ColorCorrection children."""
current_ids = [
i.cc.id
for i in self.color_decisions
if not i.is_ref and i.cc is not None and i.cc.id is not None
]
current_ids.extend(
[i.id for i in self.color_corrections if i.id is not None]
)
current_ids.sort()
return current_ids
@property
def is_ccc(self) -> bool:
"""Return True if collection type is set to 'ccc'."""
return self.type == "ccc"
@property
def is_cdl(self) -> bool:
"""Return True if collection type is set to 'cdl'."""
return self.type == "cdl"
@property
def type(self) -> str:
"""Return collection type that determines export format."""
return self._type
@type.setter
def type(self, value: str) -> None:
"""Set collection type to 'ccc' or 'cdl'."""
if value.lower() not in ["ccc", "cdl"]:
raise ValidationError(
"ColorCollection type must be set to either ccc or cdl."
)
else:
self._type = value.lower()
@property
def xmlns(self) -> str:
"""Return XML namespace URI for ASC CDL schema version."""
return self._xmlns
# Private Methods =========================================================
@staticmethod
def _list_setter(
list_name: str,
color_class: builtins.type[Any],
values: None | Any | list[Any] | tuple[Any, ...] | set,
) -> list[Any]:
"""Set list to provided values after validating."""
if values is None:
return []
elif type(values) in [list, tuple, set]:
for color in values:
# We need to make sure each member is of the correct class.
if color.__class__ != color_class:
raise ValidationError(
f"ColorCollection().{list_name} cannot be set to "
f"provided list because not all members of that list "
f"are of the {color_class.__name__} class."
)
return list(set(values))
elif values.__class__ == color_class:
# If we just got passed the correct class, we'll return it as a
# one member list.
return [values]
else:
raise ValidationError(
f"ColorCollection().{list_name} cannot be set to item "
f"of type '{type(values)}'. Please set {list_name} with a "
f"list containing only members of class {color_class.__name__}."
)
# Public Methods ==========================================================
[docs]
def append_child(self, child: ColorCorrection | ColorDecision) -> bool:
"""Add a ColorCorrection or ColorDecision to the appropriate list.
Args:
child: ColorCorrection or ColorDecision instance to add.
Returns:
bool: True if child was added successfully, False if duplicate ID
prevented addition.
Raises:
ValidationError: If child is not a ColorCorrection or
ColorDecision, or if duplicate ID is found and halt_on_error
is enabled.
"""
# We need to make sure not to append a ColorDecision or ColorCorrection
# if that id attribute already exists as a direct child or a child of a
# ColorDecision child.
#
# This is only possible through the python API- assigning
# the same ColorCorrection object to multiple ColorDecisions,
# or clearing the member dictionary and creating a second
# ColorCorrection with the same id, etc.
dup = False
if isinstance(child, ColorCorrection):
if child.id in self.id_list:
dup = True
else:
self._color_corrections.append(child)
elif isinstance(child, ColorDecision):
if (
not child.is_ref
and child.cc is not None
and child.cc.id is not None
and child.cc.id in self.id_list
):
dup = True
else:
self._color_decisions.append(child)
else:
raise ValidationError(
"Can only append ColorCorrection and ColorDecision objects."
)
if dup:
if config.config.halt_on_error:
raise ValidationError(
"Attempted to put a ColorDecision with a child "
"ColorCorrection id that duplicates an id of a "
"ColorCorrection that is already a child of "
"this collection or a ColorDecision that is a "
"child of this collection."
)
return False
else:
child.parent = self
return True
# =========================================================================
[docs]
def append_children(
self, children: Sequence[ColorCorrection | ColorDecision]
) -> None:
"""Add multiple ColorCorrection and ColorDecision objects to lists.
Args:
children: Sequence of ColorCorrection and/or ColorDecision instances
to add.
"""
for child in children:
self.append_child(child)
# =========================================================================
[docs]
def build_element(self) -> ElementTree.Element | None:
"""Build XML ElementTree Element based on current collection type.
Returns:
ElementTree.Element: CCC or CDL format XML element depending on
type.
"""
if self.is_ccc:
return self.build_element_ccc()
elif self.is_cdl:
return self.build_element_cdl()
return None
# =========================================================================
[docs]
def build_element_ccc(self) -> ElementTree.Element:
"""Build ColorCorrectionCollection XML element.
Returns:
ElementTree.Element: CCC format XML element containing all
ColorCorrections.
"""
ccc_xml = ElementTree.Element("ColorCorrectionCollection")
ccc_xml.attrib = {"xmlns": self.xmlns}
if self.input_desc:
input_desc = ElementTree.SubElement(ccc_xml, "InputDescription")
input_desc.text = self.input_desc
if self.viewing_desc:
viewing_desc = ElementTree.SubElement(ccc_xml, "ViewingDescription")
viewing_desc.text = self.viewing_desc
for description in self.desc:
desc = ElementTree.SubElement(ccc_xml, "Description")
desc.text = description
# Track which ColorCorrection objects have been added to avoid duplicates
# Use object identity (id()) rather than ColorCorrection.id to handle
# cases where ColorCorrections might not have IDs
added_objects: set[int] = set()
if self.color_corrections:
for color_correct in self.color_corrections:
if color_correct.element is not None:
ccc_xml.append(color_correct.element)
added_objects.add(id(color_correct))
if self.color_decisions:
# We'll need to extract the ColorCorrections from the
# ColorDecisions
for color_decision in self.color_decisions:
if (
color_decision.is_ref
and color_decision.cc is not None
and isinstance(color_decision.cc, ColorCorrectionRef)
):
color_correction = color_decision.cc.cc
elif color_decision.cc is not None and isinstance(
color_decision.cc, ColorCorrection
):
color_correction = color_decision.cc
else:
color_correction = None
# We do one last check to ensure that we actually have a
# returned ColorCorrection, as ColorCorrectionRef will
# return None if it's an unresolved reference and no
# HALT behavior was set.
# Also check if this ColorCorrection object has already been added
if (
color_correction
and color_correction.element is not None
and id(color_correction) not in added_objects
):
ccc_xml.append(color_correction.element)
added_objects.add(id(color_correction))
return ccc_xml
# =========================================================================
[docs]
def build_element_cdl(self) -> ElementTree.Element:
"""Build ColorDecisionList XML element.
Returns:
ElementTree.Element: CDL format XML element containing all
ColorDecisions.
"""
cdl_xml = ElementTree.Element("ColorDecisionList")
cdl_xml.attrib = {"xmlns": self.xmlns}
if self.input_desc:
input_desc = ElementTree.SubElement(cdl_xml, "InputDescription")
input_desc.text = self.input_desc
if self.viewing_desc:
viewing_desc = ElementTree.SubElement(cdl_xml, "ViewingDescription")
viewing_desc.text = self.viewing_desc
for description in self.desc:
desc = ElementTree.SubElement(cdl_xml, "Description")
desc.text = description
if self.color_decisions:
for color_decision in self.color_decisions:
if (
color_decision.cc is not None
and color_decision.cc.id is not None
and color_decision.cc.id in self.id_list
):
resolve = False
else:
try:
if color_decision.cc is not None and isinstance(
color_decision.cc, ColorCorrectionRef
):
color_correction = color_decision.cc.cc
else:
color_correction = None
except ValueError:
# ValueError will be raised if we can't resolve the
# reference. This shouldn't be a game-stopper here.
#
# We'll just add the unresolved reference
resolve = False
else:
resolve = True if color_correction else False
element = color_decision.build_element(resolve=resolve)
if element is not None:
cdl_xml.append(element)
if self.color_corrections:
# We'll create some temporary ColorDecision instances, and place
# the ColorCorrects inside of them.
#
# We need to store the ColorDecision member dictionary, so that
# we can return it to the state it was in prior to us creating
# these temporary ColorDecisions
color_decisions_members = ColorDecision.members
for color_correction in self.color_corrections:
color_decision = ColorDecision(color_correction)
if color_decision.element is not None:
cdl_xml.append(color_decision.element)
# Now reset the ColorDecision member dictionary to the state it was
# in prior to us creating temp ColorDecisions
ColorDecision.members = color_decisions_members
return cdl_xml
# =========================================================================
[docs]
def copy_collection(self) -> "ColorCollection":
"""Create a copy of this collection with the same attributes.
Returns:
ColorCollection: New collection instance with copied attributes
and child references.
Note:
Child objects are referenced, not deep copied.
"""
# TODO: Add deep copy ability
new_col = ColorCollection()
new_col.desc = self.desc
new_col.file_in = self.file_in if self.file_in else None
new_col.input_desc = self.input_desc
new_col.viewing_desc = self.viewing_desc
new_col.type = self.type
new_col.append_children(self.all_children)
return new_col
# =========================================================================
[docs]
def determine_dest(self, directory: str | Path) -> None:
"""Set output file path based on input filename or collection index.
Args:
directory: Directory path where output file will be written.
"""
if self.file_in:
filename = Path(self.file_in).stem
else:
filename = f"color_collection_{str(ColorCollection.members.index(self)).rjust(3, '0')}"
filename = f"{filename}.{self.type}"
self._file_out = Path(directory) / filename
# =========================================================================
[docs]
def merge_collections(
self, collections: list["ColorCollection"]
) -> "ColorCollection":
"""Merge collection with others to create a new combined collection.
Args:
collections: List of ColorCollection instances to merge with.
Returns:
ColorCollection: New collection containing children from all input
collections, with duplicates removed and attributes from this
collection preserved.
"""
new_col = self.copy_collection()
# We need to move all the children into one big list, so that
# we can move it into a set to eliminate duplicates.
children = new_col.all_children
# Now that all of new_col's children are in the children list,
# let's clear out the new_col lists.
# We'll repopulate them with the full lists after adding
# all the additional children.
new_col.color_corrections = []
new_col.color_decisions = []
for col in collections:
if col == self: # Don't add ourselves
continue
new_col.desc.extend(col.desc)
children.extend(col.all_children)
new_col.append_children(children)
# Remove duplicates
new_col.color_corrections = set(new_col.color_corrections)
new_col.color_decisions = set(new_col.color_decisions)
return new_col
# =========================================================================
[docs]
def parse_xml_color_corrections(
self, xml_element: ElementTree.Element
) -> bool:
"""Parse XML element to find and add ColorCorrection elements.
Args:
xml_element: XML element to search for ColorCorrection child
elements.
Returns:
bool: True if ColorCorrection elements were found and added.
"""
from . import parse
cc_nodes = xml_element.findall("ColorCorrection")
if not cc_nodes:
return False
for cc_node in xml_element.findall("ColorCorrection"):
cdl = parse.parse_cc(cc_node)
cdl.parent = self
self._color_corrections.append(cdl)
return True
# =========================================================================
[docs]
def parse_xml_color_decisions(
self, xml_element: ElementTree.Element
) -> bool:
"""Parse XML element to find and add ColorDecision elements.
Args:
xml_element: XML element to search for ColorDecision child
elements.
Returns:
bool: True if ColorDecision elements were found and added.
"""
cd_nodes = xml_element.findall("ColorDecision")
if not cd_nodes:
return False
for cd_node in xml_element.findall("ColorDecision"):
color_decision = ColorDecision()
color_decision.parse_xml_color_decision(cd_node)
color_decision.parent = self
self._color_decisions.append(color_decision)
return True
# =========================================================================
[docs]
@classmethod
def reset_members(cls) -> None:
"""Clear the class-level members list."""
cls.members = []
# =========================================================================
[docs]
def set_parentage(self) -> None:
"""Set the parent attribute of all child objects to this collection."""
for node in self.all_children:
node.parent = self
# =========================================================================
[docs]
def set_to_ccc(self) -> None:
"""Set collection type to 'ccc' for ColorCorrectionCollection."""
self._type = "ccc"
# =========================================================================
[docs]
def set_to_cdl(self) -> None:
"""Set collection type to 'cdl' for ColorDecisionList format."""
self._type = "cdl"