diff --git a/src/apps/pyocioamf/README.md b/src/apps/pyocioamf/README.md index 4a66cdd1a6..36993d87dd 100644 --- a/src/apps/pyocioamf/README.md +++ b/src/apps/pyocioamf/README.md @@ -4,20 +4,47 @@ pyocioamf ========= -**Work in progress**. Script to convert an ACES Metadata File (AMF) into OCIO's -native CTF file format. The CTF may then be used with any of the other OCIO tools -to process pixels. +Script to convert an ACES Metadata File (AMF) into OCIO's native CTF file format. +The CTF may then be used with any of the other OCIO tools to process pixels. -An example AMF file named ``example.amf`` is provided. It references the LUT file -``example_referenced_lut.clf``, which is in the Academy/ASC Common LUT Format (CLF). +**Supports both AMF v1.0 and v2.0** with automatic version detection. + +Example files are provided: +- ``example.amf`` - AMF v1.0 format, references ``example_referenced_lut.clf`` +- ``example_v2.amf`` - AMF v2.0 format + +The referenced LUT file is in the Academy/ASC Common LUT Format (CLF). Usage ----- -1. Install dependencies on ``PYTHONPATH`` -2. Run ``python pyocioamf.py example.amf`` (or any other AMF file). -3. The CTF file will have the same name as the AMF file but with a .ctf extension, in -this case, ``example.ctf``. +Basic usage: + +```bash +python pyocioamf.py example.amf +``` + +The CTF file will have the same name as the AMF file but with a .ctf extension. + +### Command-Line Options + +```bash +# Use a specific OCIO config file +python pyocioamf.py example.amf --config path/to/config.ocio + +# Exclude specific components from conversion +python pyocioamf.py example.amf --no-idt # Exclude input transform +python pyocioamf.py example.amf --no-lmt # Exclude look transforms +python pyocioamf.py example.amf --no-odt # Exclude output transform +python pyocioamf.py example.amf --no-lmt --no-odt # Exclude multiple + +# Split CTF generation for AMF v2.0 workingLocation marker +python pyocioamf.py example.amf --split-by-working-location +``` + +The ``--split-by-working-location`` option generates two CTF files: +- ``*_before.ctf``: IDT + look transforms before the workingLocation marker +- ``*_after.ctf``: Look transforms after the marker + ODT Dependencies ------------ @@ -27,6 +54,11 @@ Dependencies Implementation notes -------------------- +* Automatically detects AMF version (v1.0 or v2.0) from namespace URI or version attribute +* Supports both ASC CDL element naming conventions (``SOPNode``/``SatNode`` and ``ASC_SOP``/``ASC_SAT``) * Uses a prototype ACES config ``config-aces-reference.yaml`` to interpret the - ACES Transform IDs encountered in the AMF file. This file should be in the + ACES Transform IDs encountered in the AMF file. This file should be in the same directory as the script. +* Alternatively, use ``--config`` to specify a custom OCIO config file +* For OCIO 2.5+ configs, uses the ``amf_transform_ids`` interchange attribute for transform lookup +* For OCIO 2.1-2.4 configs, searches transform IDs in the description field diff --git a/src/apps/pyocioamf/example_v2.amf b/src/apps/pyocioamf/example_v2.amf new file mode 100644 index 0000000000..11fb793137 --- /dev/null +++ b/src/apps/pyocioamf/example_v2.amf @@ -0,0 +1,71 @@ + + + + Example Movie + + Foo Bar + Foobar@onset.com + + + 2019-09-19T13:20:00 + 2019-11-27T13:20:00Z + + urn:uuid:afe122be-59d3-4360-ad69-33c10108fa7a + + + A001C012 + A001_C012_AE0306_###.exr + + + + Example Movie Final DI + + 2019-09-19T13:20:00 + 2019-11-27T13:20:00Z + + urn:uuid:be6Ec2ea-a6DC-6cBC-ff0D-AfCED5FF3Dd8 + + 1 + 0 + 3 + + + + IDT from Acme Camera Company + 1531ea6ef06c5b0a5bea80c94f60c7b68e3989e3c90b8ebd25c28aa4670c30f8 + urn:ampas:aces:transformId:v1.5:IDT.Acme.Camera.a1.v1 + + + Technical Grade + + + urn:ampas:aces:transformId:v1.5:ACEScsc.Academy.ACEScct_to_ACES.a1.0.3 + + + + 2.0 2.0 2.0 + 0.1 0.1 0.1 + 1 1 1 + + + 1 + + + + + ACES v1.0.3 RRT + c81af4fb4a22ee0353308e4582708951df4682bf73f838c24bf44e585fc3bb61 + urn:ampas:aces:transformId:v1.5:RRT.a1.0.3 + + + P3D60 ODT + efd279a82c2d52ee8c49dc0793499dc86bb1a4a3fa0dfb420d59c2814c55aea6 + urn:ampas:aces:transformId:v1.5:ODT.Academy.P3D60_48nits.a1.0.3 + + + + diff --git a/src/apps/pyocioamf/pyocioamf.py b/src/apps/pyocioamf/pyocioamf.py index 853bf8683c..d03d0730dd 100644 --- a/src/apps/pyocioamf/pyocioamf.py +++ b/src/apps/pyocioamf/pyocioamf.py @@ -7,45 +7,177 @@ This is a prototype for AMF support in OCIO. It utilizes a tweaked version of the OCIO v2 ACES Reference config as a database of AMF Transform ID strings and accompanying transforms. -Usage: % python pyocioamf.py +Usage: % python pyocioamf.py [options] + +Options: + --no-idt Exclude Input Transform (IDT) from conversion + --no-lmt Exclude Look Transform(s) (LMT) from conversion + --no-odt Exclude Output Transform (ODT) from conversion + --split-by-working-location Generate split CTFs based on workingLocation marker (AMF v2.0) The exported CTF file is written to the same directory as the AMF_FILE and has the same -name, but uses .ctf as the extension. The CTF file may then be used with any of the -other OCIO tools such as ocioconvert or ociochecklut to apply the AMF color pipeline. +name, but uses .ctf as the extension. When using --split-by-working-location, two files +are generated: *_before.ctf (IDT + looks before marker) and *_after.ctf (looks after +marker + ODT). + +The CTF file may then be used with any of the other OCIO tools such as ocioconvert or +ociochecklut to apply the AMF color pipeline. """ +import argparse import xml.etree.ElementTree as ET import PyOpenColorIO as ocio -# Specify the color space name for ACES2065-1 in the ACES config. -ACES = 'ACES - ACES2065-1' -# Setup the namespaces used in the AMF XML file. -NS = {'aces': 'urn:ampas:aces:amf:v1.0', 'cdl': 'urn:ASC:CDL:v1.01'} +# Namespace URIs for AMF versions +AMF_NS_V1 = 'urn:ampas:aces:amf:v1.0' +AMF_NS_V2 = 'urn:ampas:aces:amf:v2.0' +CDL_NS = 'urn:ASC:CDL:v1.01' + +# Default namespace dict (will be updated based on detected version) +NS = {'aces': AMF_NS_V1, 'cdl': CDL_NS} + + +def get_ocio_major_minor_version(config): + """ + Get the OCIO config profile version as a tuple (major, minor). + Returns (2, 1) as minimum if version cannot be determined. + """ + try: + # getMajorVersion() and getMinorVersion() available in OCIO 2.x + major = config.getMajorVersion() + minor = config.getMinorVersion() + return (major, minor) + except AttributeError: + # Fallback for older OCIO versions + return (2, 1) + + +def find_aces_colorspace_name(config): + """ + Find the ACES2065-1 colorspace name in the config. + Uses the aces_interchange role which is defined in all ACES configs. + """ + if config.hasRole('aces_interchange'): + return config.getRoleColorSpace('aces_interchange') + # Fallback for non-standard configs + return 'ACES2065-1' + + +def has_amf_transform_ids_support(config): + """ + Check if the OCIO config supports the amf_transform_ids attribute (OCIO 2.5+). + """ + major, minor = get_ocio_major_minor_version(config) + return (major, minor) >= (2, 5) + + +def get_amf_transform_ids(element): + """ + Get AMF Transform IDs from an OCIO config element (colorspace, view transform, look). + Works with OCIO 2.5+ configs that have the interchange/amf_transform_ids attribute. + Returns a list of transform ID strings, or empty list if not available. + """ + try: + # OCIO 2.5+ API: getInterchangeAttribute("amf_transform_ids") + # Returns a newline-separated string of transform IDs + if hasattr(element, 'getInterchangeAttribute'): + amf_ids_str = element.getInterchangeAttribute("amf_transform_ids") + if amf_ids_str: + # Split by newlines and filter out empty strings + return [id.strip() for id in amf_ids_str.split('\n') if id.strip()] + except: + pass + return [] + + +def detect_amf_version(root): + """ + Detect AMF version from the root element's namespace or version attribute. + Returns the version string ('1.0' or '2.0') and updates the global NS dict. + """ + global NS -def search_colorspaces(config, aces_id): - """ Search the config for the supplied ACES ID, return the color space name. """ + # Check the namespace of the root element + ns_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else '' + + # Also check the version attribute + version_attr = root.attrib.get('version', '') + + if ns_uri == AMF_NS_V2 or version_attr == '2.0': + NS = {'aces': AMF_NS_V2, 'cdl': CDL_NS} + return '2.0' + else: + NS = {'aces': AMF_NS_V1, 'cdl': CDL_NS} + return '1.0' + +def search_colorspaces(config, aces_id, use_amf_ids=False): + """ + Search the config for the supplied ACES ID, return the color space name. + + Args: + config: OCIO config object + aces_id: ACES Transform ID to search for + use_amf_ids: If True, search in amf_transform_ids (OCIO 2.5+), else search in description + """ for cs in config.getColorSpaces(): - desc = cs.getDescription() - if aces_id in desc: - return cs.getName() + if use_amf_ids: + # OCIO 2.5+: Search in amf_transform_ids + amf_ids = get_amf_transform_ids(cs) + if aces_id in amf_ids: + return cs.getName() + else: + # Legacy: Search in description + desc = cs.getDescription() + if aces_id in desc: + return cs.getName() return None -def search_viewtransforms(config, aces_id): - """ Search the config for the supplied ACES ID, return the view transform name. """ + +def search_viewtransforms(config, aces_id, use_amf_ids=False): + """ + Search the config for the supplied ACES ID, return the view transform name. + + Args: + config: OCIO config object + aces_id: ACES Transform ID to search for + use_amf_ids: If True, search in amf_transform_ids (OCIO 2.5+), else search in description + """ for vt in config.getViewTransforms(): - desc = vt.getDescription() - if aces_id in desc: - return vt.getName() + if use_amf_ids: + # OCIO 2.5+: Search in amf_transform_ids + amf_ids = get_amf_transform_ids(vt) + if aces_id in amf_ids: + return vt.getName() + else: + # Legacy: Search in description + desc = vt.getDescription() + if aces_id in desc: + return vt.getName() return None -def search_looktransforms(config, aces_id): - """ Search the config for the supplied ACES ID, return the look name. """ + +def search_looktransforms(config, aces_id, use_amf_ids=False): + """ + Search the config for the supplied ACES ID, return the look name. + + Args: + config: OCIO config object + aces_id: ACES Transform ID to search for + use_amf_ids: If True, search in amf_transform_ids (OCIO 2.5+), else search in description + """ for lt in config.getLooks(): - desc = lt.getDescription() - if aces_id in desc: - return lt.getName() + if use_amf_ids: + # OCIO 2.5+: Search in amf_transform_ids + amf_ids = get_amf_transform_ids(lt) + if aces_id in amf_ids: + return lt.getName() + else: + # Legacy: Search in description + desc = lt.getDescription() + if aces_id in desc: + return lt.getName() return None def must_apply(elem, type): @@ -86,6 +218,57 @@ def name_ctf_output_file(amf_path): abs_path = os.path.join(prefix, fname + '.ctf') return abs_path + +def name_ctf_output_file_split(amf_path, suffix): + """ Return the path for a split CTF file with _before or _after suffix. """ + import os.path + prefix, basename = os.path.split(amf_path) + fname, ext = os.path.splitext(basename) + return os.path.join(prefix, f'{fname}_{suffix}.ctf') + + +def split_look_transforms_by_working_location(root, ns): + """ + Split look transforms based on workingLocation marker position. + + Iterates through pipeline children to find the workingLocation marker + and categorize lookTransform elements as before or after it. + + Args: + root: XML root element + ns: Namespace dictionary + + Returns: + tuple: (before_looks, after_looks, has_working_location) + - before_looks: List of lookTransform elements before workingLocation + - after_looks: List of lookTransform elements after workingLocation + - has_working_location: Boolean indicating if marker was found + """ + pipeline = root.find('./aces:pipeline', namespaces=ns) + if pipeline is None: + return [], [], False + + before_looks = [] + after_looks = [] + found_working_location = False + + for child in pipeline: + # Get local name by stripping namespace + local_name = child.tag.split('}')[-1] if '}' in child.tag else child.tag + + if local_name == 'workingLocation': + found_working_location = True + continue + + if local_name == 'lookTransform': + if found_working_location: + after_looks.append(child) + else: + before_looks.append(child) + + return before_looks, after_looks, found_working_location + + def write_ctf(grp, config, amf_path, fname, id): """ Take a GroupTransform and some metadata and write a CTF file. """ @@ -123,15 +306,21 @@ def extract_three_floats(elem): raise return None -def parse_cdl(look_elem): - """ Return the CDL slope, offset, power, and saturation values from a look element. """ +def parse_cdl(look_elem, amf_version): + """ Return the CDL slope, offset, power, and saturation values from a look element. + Supports both ASC CDL element naming conventions: SOPNode/SatNode and ASC_SOP/ASC_SAT. + """ slopes = [1., 1., 1.] offsets = [0., 0., 0.] powers = [1., 1., 1.] sat = 1. has_cdl = False - sop_elem = look_elem.find('./cdl:SOPNode', namespaces=NS) + # Support both ASC CDL element naming conventions + sop_elem = look_elem.find('./cdl:ASC_SOP', namespaces=NS) + if sop_elem is None: + sop_elem = look_elem.find('./cdl:SOPNode', namespaces=NS) + if sop_elem is not None: slope_elem = sop_elem.find('./cdl:Slope', namespaces=NS) if slope_elem is not None: @@ -144,7 +333,11 @@ def parse_cdl(look_elem): powers = extract_three_floats(power_elem) has_cdl = True - sat_elem = look_elem.find('./cdl:SatNode', namespaces=NS) + # Support both ASC CDL saturation element naming conventions + sat_elem = look_elem.find('./cdl:ASC_SAT', namespaces=NS) + if sat_elem is None: + sat_elem = look_elem.find('./cdl:SatNode', namespaces=NS) + if sat_elem is not None: saturation_elem = sat_elem.find('./cdl:Saturation', namespaces=NS) if saturation_elem is not None: @@ -157,7 +350,7 @@ def parse_cdl(look_elem): return has_cdl, slopes, offsets, powers, sat -def load_cdl_working_space_transform(config, base_path, look_elem, is_to_direc): +def load_cdl_working_space_transform(config, base_path, look_elem, is_to_direc, use_amf_ids=False, aces_cs_name='ACES2065-1'): """ Return an OCIO transform for a CDL to/from working space transform. """ if is_to_direc: token = 'aces:toCdlWorkingSpace' @@ -173,14 +366,14 @@ def load_cdl_working_space_transform(config, base_path, look_elem, is_to_direc): if id_elem is not None: aces_id = id_elem.text - cs_name = search_colorspaces(config, aces_id) + cs_name = search_colorspaces(config, aces_id, use_amf_ids) if cs_name is not None: if is_to_direc: print(' Loading To-CDL-Working-Space Transform from ACES2065-1 to', cs_name) - transform = ocio.ColorSpaceTransform(ACES, cs_name, ocio.TRANSFORM_DIR_FORWARD) + transform = ocio.ColorSpaceTransform(aces_cs_name, cs_name, ocio.TRANSFORM_DIR_FORWARD) else: print(' Loading From-CDL-Working-Space Transform from', cs_name, 'to ACES2065-1') - transform = ocio.ColorSpaceTransform(cs_name, ACES, ocio.TRANSFORM_DIR_FORWARD) + transform = ocio.ColorSpaceTransform(cs_name, aces_cs_name, ocio.TRANSFORM_DIR_FORWARD) return transform else: raise ValueError("Could not find transform for transformId element: " + aces_id) @@ -200,7 +393,7 @@ def load_cdl_working_space_transform(config, base_path, look_elem, is_to_direc): return transform return None -def process_look(config, gt, base_path, look_elem): +def process_look(config, gt, base_path, look_elem, amf_version='1.0', use_amf_ids=False, aces_cs_name='ACES2065-1'): """ Build a look transform and add it to the supplied OCIO group transform. """ if not must_apply(look_elem, 'Look'): return @@ -211,10 +404,10 @@ def process_look(config, gt, base_path, look_elem): if id_elem is not None: aces_id = id_elem.text - look_name = search_looktransforms(config, aces_id) + look_name = search_looktransforms(config, aces_id, use_amf_ids) if look_name is not None: print('Adding Look Transform:', look_name) - gt.appendTransform( ocio.LookTransform(ACES, ACES, look_name, False, ocio.TRANSFORM_DIR_FORWARD) ) + gt.appendTransform( ocio.LookTransform(aces_cs_name, aces_cs_name, look_name, False, ocio.TRANSFORM_DIR_FORWARD) ) return else: raise ValueError("Could not find transform for transformId element: " + aces_id) @@ -240,7 +433,7 @@ def process_look(config, gt, base_path, look_elem): # # LookTransform ASC CDL case. # - has_cdl, slopes, offsets, powers, sat = parse_cdl(look_elem) + has_cdl, slopes, offsets, powers, sat = parse_cdl(look_elem, amf_version) if has_cdl: ws_elem = look_elem.find('./aces:cdlWorkingSpace', namespaces=NS) if ws_elem is None: @@ -250,8 +443,8 @@ def process_look(config, gt, base_path, look_elem): # # Attempt to load the working space transforms. # - to_transform = load_cdl_working_space_transform(config, base_path, ws_elem, True) - from_transform = load_cdl_working_space_transform(config, base_path, ws_elem, False) + to_transform = load_cdl_working_space_transform(config, base_path, ws_elem, True, use_amf_ids, aces_cs_name) + from_transform = load_cdl_working_space_transform(config, base_path, ws_elem, False, use_amf_ids, aces_cs_name) # # Handle the four possible scenarios of working space transform availability. # @@ -285,20 +478,20 @@ def process_look(config, gt, base_path, look_elem): print('Adding From-CDL-Working-Space Transform') gt.appendTransform(from_transform) -def build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir): +def build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir, use_amf_ids=False, aces_cs_name='ACES2065-1'): """ Add an OCIO transform to the supplied group transform using a transform ID. """ aces_id = id_elem.text - dcs_name = search_colorspaces(config, aces_id) - vt_name = search_viewtransforms(config, aces_id) + dcs_name = search_colorspaces(config, aces_id, use_amf_ids) + vt_name = search_viewtransforms(config, aces_id, use_amf_ids) if dcs_name is not None and vt_name is not None: print( msg % (dcs_name, vt_name) ) - gt.appendTransform( ocio.DisplayViewTransform(ACES, dcs_name, vt_name, direction=transform_dir) ) + gt.appendTransform( ocio.DisplayViewTransform(aces_cs_name, dcs_name, vt_name, direction=transform_dir) ) else: raise ValueError("Could not process transformId element: " + aces_id) -def load_output_transform(config, gt, base_path, elem, is_inverse): +def load_output_transform(config, gt, base_path, elem, is_inverse, use_amf_ids=False, aces_cs_name='ACES2065-1'): """ Add an OCIO transform to the supplied group transform to implement an ACES Output Transform. """ # Setup some variables based on whether it is an Output or Inverse Output Transform. if not is_inverse: @@ -322,7 +515,7 @@ def load_output_transform(config, gt, base_path, elem, is_inverse): # id_elem = elem.find(ot_transformId_token, namespaces=NS) if id_elem is not None: - build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir) + build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir, use_amf_ids, aces_cs_name) return # # OutputTransform external LUT file case. @@ -345,7 +538,7 @@ def load_output_transform(config, gt, base_path, elem, is_inverse): # id_elem = odt_elem.find('./aces:transformId', namespaces=NS) if id_elem is not None: - build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir) + build_output_transform_from_id_elem(config, gt, id_elem, msg, transform_dir, use_amf_ids, aces_cs_name) return # TODO: Could validate that the referenceRenderingTransform element exists and is # the expected version (although since there is only one, it is not currently used). @@ -383,7 +576,7 @@ def load_output_transform(config, gt, base_path, elem, is_inverse): print('Adding ODT LUT file:', odt_path) gt.appendTransform( odt_transform ) -def load_input_transform(config, gt, base_path, input_elem): +def load_input_transform(config, gt, base_path, input_elem, use_amf_ids=False, aces_cs_name='ACES2065-1'): """ Add an OCIO transform to the supplied group transform to implement an ACES Input Transform. """ # # InputTransform ACES transformId case. @@ -392,10 +585,10 @@ def load_input_transform(config, gt, base_path, input_elem): if id_elem is not None: aces_id = id_elem.text - cs_name = search_colorspaces(config, aces_id) + cs_name = search_colorspaces(config, aces_id, use_amf_ids) if cs_name is not None: print('Adding Input Transform from', cs_name, 'to ACES2065-1') - gt.appendTransform( ocio.ColorSpaceTransform(cs_name, ACES, ocio.TRANSFORM_DIR_FORWARD) ) + gt.appendTransform( ocio.ColorSpaceTransform(cs_name, aces_cs_name, ocio.TRANSFORM_DIR_FORWARD) ) return else: raise ValueError("Could not find transform for transformId element: " + aces_id) @@ -413,34 +606,76 @@ def load_input_transform(config, gt, base_path, input_elem): # # InputTransform is an inverse OutputTransform case. # - load_output_transform(config, gt, base_path, input_elem, is_inverse=True) + load_output_transform(config, gt, base_path, input_elem, is_inverse=True, use_amf_ids=use_amf_ids, aces_cs_name=aces_cs_name) + +def load_aces_ref_config(config_path=None): + """ + Return an OCIO config object for the ACES reference config required to decode transform IDs. -def load_aces_ref_config(): - """ Return an OCIO config object for the ACES reference config required to decode transform IDs. """ - ACES_REF_CONFIG = 'config-aces-reference.yaml' + Args: + config_path: Optional path to OCIO config file. If None, uses default config. + """ import os - # Get the path to this script, see if the config is in the same directory. - file_path = os.path.realpath(__file__) - prefix = os.path.dirname(file_path) - config_path = os.path.join(prefix, ACES_REF_CONFIG) + + if config_path is None: + # Default: look for config-aces-reference.yaml in the same directory as this script + ACES_REF_CONFIG = 'config-aces-reference.yaml' + file_path = os.path.realpath(__file__) + prefix = os.path.dirname(file_path) + config_path = os.path.join(prefix, ACES_REF_CONFIG) + + if not os.path.isfile(config_path): + raise ValueError(f'OCIO config file not found: {config_path}') + config = ocio.Config().CreateFromFile(config_path) - # TODO: Try other ways of finding the config. return config -def build_ctf(amf_path): - """ Build a color pipeline that implements the supplied AMF file and write out a CTF file. """ +def build_ctf(amf_path, exclude_idt=False, exclude_lmt=False, exclude_odt=False, config_path=None): + """ Build a color pipeline that implements the supplied AMF file and write out a CTF file. + + Args: + amf_path: Path to the AMF file + exclude_idt: If True, exclude Input Transform (IDT) from conversion + exclude_lmt: If True, exclude Look Transform(s) (LMT) from conversion + exclude_odt: If True, exclude Output Transform (ODT) from conversion + config_path: Optional path to OCIO config file. If None, uses default config. + """ print('\nProcessing file: ', amf_path, '\n') # Use Python to parse the AMF XML file and return an Element Tree object. tree = ET.parse(amf_path) root = tree.getroot() + # Detect AMF version and configure namespaces accordingly + amf_version = detect_amf_version(root) + print('Detected AMF version:', amf_version) + + # Print exclusion status + if exclude_idt or exclude_lmt or exclude_odt: + excluded = [] + if exclude_idt: + excluded.append('IDT') + if exclude_lmt: + excluded.append('LMT') + if exclude_odt: + excluded.append('ODT') + print('Excluding components:', ', '.join(excluded)) + # Clear the OCIO file cache (helpful if someone is editing files in between running this). ocio.ClearAllCaches() # Load the ACES Reference config that will be used to implement any Transform ID strings # encountered in the AMF file. - config = load_aces_ref_config() + config = load_aces_ref_config(config_path) + + # Detect OCIO config version and capabilities + ocio_version = get_ocio_major_minor_version(config) + use_amf_ids = has_amf_transform_ids_support(config) + aces_cs_name = find_aces_colorspace_name(config) + + print(f'OCIO config version: {ocio_version[0]}.{ocio_version[1]}') + print(f'Using AMF Transform IDs attribute: {use_amf_ids}') + print(f'ACES2065-1 colorspace name: {aces_cs_name}') # Initialize a group transform to hold the results. gt = ocio.GroupTransform() @@ -448,20 +683,29 @@ def build_ctf(amf_path): # # Handle the AMF Input Transform. # - input_elem = root.find('./aces:pipeline/aces:inputTransform', namespaces=NS) - if must_apply(input_elem, 'Input'): - load_input_transform(config, gt, amf_path, input_elem) + if not exclude_idt: + input_elem = root.find('./aces:pipeline/aces:inputTransform', namespaces=NS) + if must_apply(input_elem, 'Input'): + load_input_transform(config, gt, amf_path, input_elem, use_amf_ids, aces_cs_name) + else: + print('Skipping Input Transform (IDT) - excluded by user') # # Handle all the AMF Look Transforms. # - for look_elem in root.findall('./aces:pipeline/aces:lookTransform', namespaces=NS): - process_look(config, gt, amf_path, look_elem) + if not exclude_lmt: + for look_elem in root.findall('./aces:pipeline/aces:lookTransform', namespaces=NS): + process_look(config, gt, amf_path, look_elem, amf_version, use_amf_ids, aces_cs_name) + else: + print('Skipping Look Transform(s) (LMT) - excluded by user') # # Handle the AMF Output Transform. # - output_elem = root.find('./aces:pipeline/aces:outputTransform', namespaces=NS) - if must_apply(output_elem, 'Output'): - load_output_transform(config, gt, amf_path, output_elem, is_inverse=False) + if not exclude_odt: + output_elem = root.find('./aces:pipeline/aces:outputTransform', namespaces=NS) + if must_apply(output_elem, 'Output'): + load_output_transform(config, gt, amf_path, output_elem, is_inverse=False, use_amf_ids=use_amf_ids, aces_cs_name=aces_cs_name) + else: + print('Skipping Output Transform (ODT) - excluded by user') # Print the OCIO transforms in the group transform. print('\n', gt) @@ -471,11 +715,183 @@ def build_ctf(amf_path): write_ctf(gt, config, amf_path, ctf_path, 'none') +def build_ctf_split(amf_path, exclude_idt=False, exclude_lmt=False, exclude_odt=False, config_path=None): + """ + Build two CTF files split by workingLocation marker. + + Generates: + - *_before.ctf: IDT + lookTransforms before workingLocation + - *_after.ctf: lookTransforms after workingLocation + ODT + + If no workingLocation is found, falls back to generating a single CTF file. + + Args: + amf_path: Path to the AMF file + exclude_idt: If True, exclude Input Transform from before CTF + exclude_lmt: If True, exclude all Look Transforms from both CTFs + exclude_odt: If True, exclude Output Transform from after CTF + config_path: Optional path to OCIO config file + """ + print('\nProcessing file (split mode): ', amf_path, '\n') + + # Parse AMF XML + tree = ET.parse(amf_path) + root = tree.getroot() + + # Detect AMF version + amf_version = detect_amf_version(root) + print('Detected AMF version:', amf_version) + + # Check for workingLocation + before_looks, after_looks, has_working_location = split_look_transforms_by_working_location(root, NS) + + if not has_working_location: + print('Warning: No workingLocation element found in AMF file.') + print('Falling back to generating single CTF file.') + print('') + build_ctf(amf_path, exclude_idt, exclude_lmt, exclude_odt, config_path) + return + + print(f'Found workingLocation marker:') + print(f' - {len(before_looks)} look transform(s) before workingLocation') + print(f' - {len(after_looks)} look transform(s) after workingLocation') + + # Print exclusion status + if exclude_idt or exclude_lmt or exclude_odt: + excluded = [] + if exclude_idt: + excluded.append('IDT') + if exclude_lmt: + excluded.append('LMT') + if exclude_odt: + excluded.append('ODT') + print('Excluding components:', ', '.join(excluded)) + + # Clear OCIO cache and load config + ocio.ClearAllCaches() + config = load_aces_ref_config(config_path) + + # Detect OCIO config capabilities + ocio_version = get_ocio_major_minor_version(config) + use_amf_ids = has_amf_transform_ids_support(config) + aces_cs_name = find_aces_colorspace_name(config) + + print(f'OCIO config version: {ocio_version[0]}.{ocio_version[1]}') + print(f'Using AMF Transform IDs attribute: {use_amf_ids}') + print(f'ACES2065-1 colorspace name: {aces_cs_name}') + + # ===== BUILD "BEFORE" CTF ===== + print('\n--- Building BEFORE CTF ---') + gt_before = ocio.GroupTransform() + has_before_content = False + + # Add IDT to before CTF + if not exclude_idt: + input_elem = root.find('./aces:pipeline/aces:inputTransform', namespaces=NS) + if must_apply(input_elem, 'Input'): + load_input_transform(config, gt_before, amf_path, input_elem, use_amf_ids, aces_cs_name) + has_before_content = True + else: + print('Skipping Input Transform (IDT) - excluded by user') + + # Add look transforms BEFORE workingLocation + if not exclude_lmt: + for look_elem in before_looks: + if must_apply(look_elem, 'Look'): + process_look(config, gt_before, amf_path, look_elem, amf_version, use_amf_ids, aces_cs_name) + has_before_content = True + else: + print('Skipping Look Transform(s) (LMT) - excluded by user') + + # Write before CTF if it has content + if has_before_content: + print('\n', gt_before) + ctf_path_before = name_ctf_output_file_split(amf_path, 'before') + write_ctf(gt_before, config, amf_path, ctf_path_before, 'before') + else: + print('\nNo transforms in BEFORE CTF - skipping file generation') + + # ===== BUILD "AFTER" CTF ===== + print('\n--- Building AFTER CTF ---') + gt_after = ocio.GroupTransform() + has_after_content = False + + # Add look transforms AFTER workingLocation + if not exclude_lmt: + for look_elem in after_looks: + if must_apply(look_elem, 'Look'): + process_look(config, gt_after, amf_path, look_elem, amf_version, use_amf_ids, aces_cs_name) + has_after_content = True + else: + print('Skipping Look Transform(s) (LMT) - excluded by user') + + # Add ODT to after CTF + if not exclude_odt: + output_elem = root.find('./aces:pipeline/aces:outputTransform', namespaces=NS) + if must_apply(output_elem, 'Output'): + load_output_transform(config, gt_after, amf_path, output_elem, is_inverse=False, + use_amf_ids=use_amf_ids, aces_cs_name=aces_cs_name) + has_after_content = True + else: + print('Skipping Output Transform (ODT) - excluded by user') + + # Write after CTF if it has content + if has_after_content: + print('\n', gt_after) + ctf_path_after = name_ctf_output_file_split(amf_path, 'after') + write_ctf(gt_after, config, amf_path, ctf_path_after, 'after') + else: + print('\nNo transforms in AFTER CTF - skipping file generation') + + # Summary + print('\n--- Split CTF Generation Complete ---') + if has_before_content: + print(f' Before CTF: {name_ctf_output_file_split(amf_path, "before")}') + if has_after_content: + print(f' After CTF: {name_ctf_output_file_split(amf_path, "after")}') + + def main(): - import sys - if len( sys.argv ) != 2: - raise ValueError( "USAGE: python3 amf_to_ocio.py " ) - build_ctf( sys.argv[1] ) + parser = argparse.ArgumentParser( + description='Convert an ACES Metadata File (AMF) into an OCIO Color Transform File (CTF).', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python pyocioamf.py example.amf # Convert full pipeline + python pyocioamf.py example.amf --no-idt # Exclude input transform + python pyocioamf.py example.amf --no-lmt --no-odt # Exclude LMT and ODT + python pyocioamf.py example.amf --config my_config.ocio # Use custom OCIO config + python pyocioamf.py example.amf --split-by-working-location # Generate split CTFs + ''' + ) + parser.add_argument('amf_file', help='Path to the AMF file to convert') + parser.add_argument('--config', '-c', dest='config_path', + help='Path to OCIO config file (supports OCIO 2.1+, with enhanced support for 2.5+)') + parser.add_argument('--no-idt', action='store_true', + help='Exclude Input Transform (IDT) from conversion') + parser.add_argument('--no-lmt', action='store_true', + help='Exclude Look Transform(s) (LMT) from conversion') + parser.add_argument('--no-odt', action='store_true', + help='Exclude Output Transform (ODT) from conversion') + parser.add_argument('--split-by-working-location', action='store_true', + help='Generate two CTF files split by workingLocation marker (AMF v2.0). ' + 'Creates *_before.ctf (IDT + looks before marker) and ' + '*_after.ctf (looks after marker + ODT)') + + args = parser.parse_args() + + if args.split_by_working_location: + build_ctf_split(args.amf_file, + exclude_idt=args.no_idt, + exclude_lmt=args.no_lmt, + exclude_odt=args.no_odt, + config_path=args.config_path) + else: + build_ctf(args.amf_file, + exclude_idt=args.no_idt, + exclude_lmt=args.no_lmt, + exclude_odt=args.no_odt, + config_path=args.config_path) if __name__=='__main__':