From 63ed2f21d00a188301a844a932227d22d6fa5104 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 Dec 2024 18:40:40 +0100 Subject: [PATCH 01/49] Refactor OTIO frame range collection - Removed unused function import. - Added detailed logging for data updates. - Streamlined frame range calculations and handling. - Introduced a new class for collecting source frame ranges. - Improved readability by cleaning up code structure. --- .../publish/collect_otio_frame_ranges.py | 87 +++++++++++++++---- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 62b4cefec6..0d1f76f338 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -24,11 +24,65 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): # Not all hosts can import these modules. import opentimelineio as otio from ayon_core.pipeline.editorial import ( - get_media_range_with_retimes, otio_range_to_frame_range, otio_range_with_handles ) + if not instance.data.get("otioClip"): + self.log.debug("Skipping collect OTIO frame range.") + return + + # get basic variables + otio_clip = instance.data["otioClip"] + workfile_start = instance.data["workfileFrameStart"] + + # get ranges + otio_tl_range = otio_clip.range_in_parent() + otio_tl_range_handles = otio_range_with_handles( + otio_tl_range, instance) + + # convert to frames + range_convert = otio_range_to_frame_range + tl_start, tl_end = range_convert(otio_tl_range) + tl_start_h, tl_end_h = range_convert(otio_tl_range_handles) + frame_start = workfile_start + frame_end = frame_start + otio.opentime.to_frames( + otio_tl_range.duration, otio_tl_range.duration.rate) - 1 + + data = { + "frameStart": frame_start, + "frameEnd": frame_end, + "clipIn": tl_start, + "clipOut": tl_end - 1, + "clipInH": tl_start_h, + "clipOutH": tl_end_h - 1, + } + instance.data.update(data) + self.log.debug( + "_ data: {}".format(pformat(data))) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + +class CollectOtioSourceFrameRanges(pyblish.api.InstancePlugin): + """Getting otio ranges from otio_clip + + Adding timeline and source ranges to instance data""" + + label = "Collect OTIO Frame Ranges (with media range)" + order = pyblish.api.CollectorOrder - 0.07 + families = ["shot", "clip"] + hosts = ["hiero", "flame"] + + def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from ayon_core.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles, + ) + if not instance.data.get("otioClip"): self.log.debug("Skipping collect OTIO frame range.") return @@ -42,15 +96,13 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): otio_tl_range = otio_clip.range_in_parent() otio_src_range = otio_clip.source_range otio_avalable_range = otio_clip.available_range() - otio_tl_range_handles = otio_range_with_handles( - otio_tl_range, instance) - otio_src_range_handles = otio_range_with_handles( - otio_src_range, instance) + otio_tl_range_handles = otio_range_with_handles(otio_tl_range, instance) + otio_src_range_handles = otio_range_with_handles(otio_src_range, instance) # get source avalable start frame src_starting_from = otio.opentime.to_frames( - otio_avalable_range.start_time, - otio_avalable_range.start_time.rate) + otio_avalable_range.start_time, otio_avalable_range.start_time.rate + ) # convert to frames range_convert = otio_range_to_frame_range @@ -59,16 +111,19 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): src_start, src_end = range_convert(otio_src_range) src_start_h, src_end_h = range_convert(otio_src_range_handles) frame_start = workfile_start - frame_end = frame_start + otio.opentime.to_frames( - otio_tl_range.duration, otio_tl_range.duration.rate) - 1 + frame_end = ( + frame_start + + otio.opentime.to_frames( + otio_tl_range.duration, otio_tl_range.duration.rate + ) + - 1 + ) # in case of retimed clip and frame range should not be retimed if workfile_source_duration: # get available range trimmed with processed retimes - retimed_attributes = get_media_range_with_retimes( - otio_clip, 0, 0) - self.log.debug( - ">> retimed_attributes: {}".format(retimed_attributes)) + retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) + self.log.debug(">> retimed_attributes: {}".format(retimed_attributes)) media_in = int(retimed_attributes["mediaIn"]) media_out = int(retimed_attributes["mediaOut"]) frame_end = frame_start + (media_out - media_in) + 1 @@ -87,7 +142,5 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): "sourceEndH": src_starting_from + src_end_h - 1, } instance.data.update(data) - self.log.debug( - "_ data: {}".format(pformat(data))) - self.log.debug( - "_ instance.data: {}".format(pformat(instance.data))) + self.log.debug("_ data: {}".format(pformat(data))) + self.log.debug("_ instance.data: {}".format(pformat(instance.data))) From 8ef1d38f79319c615464935d2e6f77cf85604e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 9 Jan 2025 14:10:48 +0100 Subject: [PATCH 02/49] Refactor OTIO frame range collection plugins - Added validation function for OTIO clips. - Improved documentation for each plugin. - Enhanced logging to provide clearer debug messages. - Separated logic for collecting timeline, source, and retimed ranges into distinct classes. - Updated frame calculations to handle retimed clips more effectively. --- .../publish/collect_otio_frame_ranges.py | 227 +++++++++++------- 1 file changed, 143 insertions(+), 84 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 0d1f76f338..3cf9a5b40c 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -1,50 +1,88 @@ +"""Plugins for collecting OTIO frame ranges and related timing information. + +This module contains three plugins: +- CollectOtioFrameRanges: Collects basic timeline frame ranges +- CollectOtioSourceRanges: Collects source media frame ranges +- CollectOtioRetimedRanges: Handles retimed clip frame ranges """ -Requires: - otioTimeline -> context data attribute - review -> instance data attribute - masterLayer -> instance data attribute - otioClipRange -> instance data attribute -""" + from pprint import pformat +from typing import Any import pyblish.api +try: + import opentimelineio as otio +except ImportError: + raise RuntimeError("OpenTimelineIO is not installed.") + +from ayon_core.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles, +) + + +def validate_otio_clip(instance: Any, logger: Any) -> bool: + """Validate if instance has required OTIO clip data. + + Args: + instance: The instance to validate + logger: Logger object to use for debug messages + + Returns: + bool: True if valid, False otherwise + """ + if not instance.data.get("otioClip"): + logger.debug("Skipping collect OTIO range - no clip found.") + return False + return True class CollectOtioFrameRanges(pyblish.api.InstancePlugin): - """Getting otio ranges from otio_clip + """Collect basic timeline frame ranges from OTIO clip. - Adding timeline and source ranges to instance data""" + This plugin extracts and stores basic timeline frame ranges including + handles from the OTIO clip. + + Requires: + otioClip (otio.schema.Clip): OTIO clip object + workfileFrameStart (int): Starting frame of work file + + Provides: + frameStart (int): Start frame in timeline + frameEnd (int): End frame in timeline + clipIn (int): Clip in point + clipOut (int): Clip out point + clipInH (int): Clip in point with handles + clipOutH (int): Clip out point with handles + """ label = "Collect OTIO Frame Ranges" order = pyblish.api.CollectorOrder - 0.08 families = ["shot", "clip"] hosts = ["resolve", "hiero", "flame", "traypublisher"] - def process(self, instance): - # Not all hosts can import these modules. - import opentimelineio as otio - from ayon_core.pipeline.editorial import ( - otio_range_to_frame_range, - otio_range_with_handles - ) + def process(self, instance: Any) -> None: + """Process the instance to collect frame ranges. - if not instance.data.get("otioClip"): - self.log.debug("Skipping collect OTIO frame range.") + Args: + instance: The instance to process + """ + + if not validate_otio_clip(instance, self.log): return - # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] - # get ranges + # Get timeline ranges otio_tl_range = otio_clip.range_in_parent() - otio_tl_range_handles = otio_range_with_handles( - otio_tl_range, instance) + otio_tl_range_handles = otio_range_with_handles(otio_tl_range, instance) + + # Convert to frames + tl_start, tl_end = otio_range_to_frame_range(otio_tl_range) + tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles) - # convert to frames - range_convert = otio_range_to_frame_range - tl_start, tl_end = range_convert(otio_tl_range) - tl_start_h, tl_end_h = range_convert(otio_tl_range_handles) frame_start = workfile_start frame_end = frame_start + otio.opentime.to_frames( otio_tl_range.duration, otio_tl_range.duration.rate) - 1 @@ -58,89 +96,110 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): "clipOutH": tl_end_h - 1, } instance.data.update(data) - self.log.debug( - "_ data: {}".format(pformat(data))) - self.log.debug( - "_ instance.data: {}".format(pformat(instance.data))) + self.log.debug(f"Added frame ranges: {pformat(data)}") -class CollectOtioSourceFrameRanges(pyblish.api.InstancePlugin): - """Getting otio ranges from otio_clip +class CollectOtioSourceRanges(pyblish.api.InstancePlugin): + """Collect source media frame ranges from OTIO clip. - Adding timeline and source ranges to instance data""" + This plugin extracts and stores source media frame ranges including + handles from the OTIO clip. - label = "Collect OTIO Frame Ranges (with media range)" + Requires: + otioClip (otio.schema.Clip): OTIO clip object + + Provides: + sourceStart (int): Source media start frame + sourceEnd (int): Source media end frame + sourceStartH (int): Source media start frame with handles + sourceEndH (int): Source media end frame with handles + """ + + label = "Collect Source OTIO Frame Ranges" order = pyblish.api.CollectorOrder - 0.07 families = ["shot", "clip"] hosts = ["hiero", "flame"] - def process(self, instance): - # Not all hosts can import these modules. - import opentimelineio as otio - from ayon_core.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles, - ) + def process(self, instance: Any) -> None: + """Process the instance to collect source frame ranges. - if not instance.data.get("otioClip"): - self.log.debug("Skipping collect OTIO frame range.") + Args: + instance: The instance to process + """ + + if not validate_otio_clip(instance, self.log): return - # get basic variables otio_clip = instance.data["otioClip"] - workfile_start = instance.data["workfileFrameStart"] - workfile_source_duration = instance.data.get("shotDurationFromSource") - # get ranges - otio_tl_range = otio_clip.range_in_parent() + # Get source ranges otio_src_range = otio_clip.source_range - otio_avalable_range = otio_clip.available_range() - otio_tl_range_handles = otio_range_with_handles(otio_tl_range, instance) + otio_available_range = otio_clip.available_range() otio_src_range_handles = otio_range_with_handles(otio_src_range, instance) - # get source avalable start frame + # Get source available start frame src_starting_from = otio.opentime.to_frames( - otio_avalable_range.start_time, otio_avalable_range.start_time.rate + otio_available_range.start_time, + otio_available_range.start_time.rate ) - # convert to frames - range_convert = otio_range_to_frame_range - tl_start, tl_end = range_convert(otio_tl_range) - tl_start_h, tl_end_h = range_convert(otio_tl_range_handles) - src_start, src_end = range_convert(otio_src_range) - src_start_h, src_end_h = range_convert(otio_src_range_handles) - frame_start = workfile_start - frame_end = ( - frame_start - + otio.opentime.to_frames( - otio_tl_range.duration, otio_tl_range.duration.rate - ) - - 1 - ) - - # in case of retimed clip and frame range should not be retimed - if workfile_source_duration: - # get available range trimmed with processed retimes - retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) - self.log.debug(">> retimed_attributes: {}".format(retimed_attributes)) - media_in = int(retimed_attributes["mediaIn"]) - media_out = int(retimed_attributes["mediaOut"]) - frame_end = frame_start + (media_out - media_in) + 1 - self.log.debug(frame_end) + # Convert to frames + src_start, src_end = otio_range_to_frame_range(otio_src_range) + src_start_h, src_end_h = otio_range_to_frame_range(otio_src_range_handles) data = { - "frameStart": frame_start, - "frameEnd": frame_end, - "clipIn": tl_start, - "clipOut": tl_end - 1, - "clipInH": tl_start_h, - "clipOutH": tl_end_h - 1, "sourceStart": src_starting_from + src_start, "sourceEnd": src_starting_from + src_end - 1, "sourceStartH": src_starting_from + src_start_h, "sourceEndH": src_starting_from + src_end_h - 1, } instance.data.update(data) - self.log.debug("_ data: {}".format(pformat(data))) - self.log.debug("_ instance.data: {}".format(pformat(instance.data))) + self.log.debug(f"Added source ranges: {pformat(data)}") + + +class CollectOtioRetimedRanges(pyblish.api.InstancePlugin): + """Update frame ranges for retimed clips. + + This plugin updates the frame end value for retimed clips. + + Requires: + otioClip (otio.schema.Clip): OTIO clip object + workfileFrameStart (int): Starting frame of work file + shotDurationFromSource (Optional[int]): Duration from source if retimed + + Provides: + frameEnd (int): Updated end frame for retimed clips + """ + + label = "Collect Retimed OTIO Frame Ranges" + order = pyblish.api.CollectorOrder - 0.06 + families = ["shot", "clip"] + hosts = ["hiero", "flame"] + + def process(self, instance: Any) -> None: + """Process the instance to handle retimed clips. + + Args: + instance: The instance to process + """ + if not validate_otio_clip(instance, self.log): + return + + workfile_source_duration = instance.data.get("shotDurationFromSource") + if not workfile_source_duration: + self.log.debug("No source duration found, skipping retime handling.") + return + + otio_clip = instance.data["otioClip"] + frame_start = instance.data["frameStart"] + + # Handle retimed clip frame range + retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) + self.log.debug(f"Retimed attributes: {retimed_attributes}") + + media_in = int(retimed_attributes["mediaIn"]) + media_out = int(retimed_attributes["mediaOut"]) + frame_end = frame_start + (media_out - media_in) + 1 + + instance.data["frameEnd"] = frame_end + self.log.debug(f"Updated frameEnd for retimed clip: {frame_end}") From 5d73b318ec5807c170653bee914959de27e13e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 9 Jan 2025 14:13:24 +0100 Subject: [PATCH 03/49] Refactor type hints in validation functions - Removed type hints for `Any` in function definitions. - Simplified the `validate_otio_clip` and `process` methods across multiple classes. - Cleaned up code for better readability without changing functionality. --- .../plugins/publish/collect_otio_frame_ranges.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 3cf9a5b40c..7c31b6445a 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -7,7 +7,6 @@ This module contains three plugins: """ from pprint import pformat -from typing import Any import pyblish.api @@ -23,7 +22,7 @@ from ayon_core.pipeline.editorial import ( ) -def validate_otio_clip(instance: Any, logger: Any) -> bool: +def validate_otio_clip(instance, logger): """Validate if instance has required OTIO clip data. Args: @@ -62,7 +61,7 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): families = ["shot", "clip"] hosts = ["resolve", "hiero", "flame", "traypublisher"] - def process(self, instance: Any) -> None: + def process(self, instance): """Process the instance to collect frame ranges. Args: @@ -120,7 +119,7 @@ class CollectOtioSourceRanges(pyblish.api.InstancePlugin): families = ["shot", "clip"] hosts = ["hiero", "flame"] - def process(self, instance: Any) -> None: + def process(self, instance): """Process the instance to collect source frame ranges. Args: @@ -176,7 +175,7 @@ class CollectOtioRetimedRanges(pyblish.api.InstancePlugin): families = ["shot", "clip"] hosts = ["hiero", "flame"] - def process(self, instance: Any) -> None: + def process(self, instance): """Process the instance to handle retimed clips. Args: From f90bc8f6b7c68eb79f9b199238e4f7ee1d28d8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 24 Jan 2025 14:42:11 +0100 Subject: [PATCH 04/49] Update client/ayon_core/plugins/publish/collect_otio_frame_ranges.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/publish/collect_otio_frame_ranges.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 7c31b6445a..83dbec1fe9 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -37,6 +37,7 @@ def validate_otio_clip(instance, logger): return False return True + class CollectOtioFrameRanges(pyblish.api.InstancePlugin): """Collect basic timeline frame ranges from OTIO clip. From 971c4aef43b5dad3f74b8f8ff321ef66c2927381 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 24 Jan 2025 14:52:25 +0100 Subject: [PATCH 05/49] Refactor OTIO frame range collection plugin Merged multiple plugins into a single one for collecting OTIO frame ranges. - Unified handling of timeline, source media, and retimed clip ranges. - Updated class name and docstrings for clarity. - Simplified process method to streamline range collection logic. --- .../publish/collect_otio_frame_ranges.py | 127 ++++++------------ 1 file changed, 40 insertions(+), 87 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 83dbec1fe9..917b9ad206 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -1,20 +1,15 @@ -"""Plugins for collecting OTIO frame ranges and related timing information. +"""Plugin for collecting OTIO frame ranges and related timing information. -This module contains three plugins: -- CollectOtioFrameRanges: Collects basic timeline frame ranges -- CollectOtioSourceRanges: Collects source media frame ranges -- CollectOtioRetimedRanges: Handles retimed clip frame ranges +This module contains a unified plugin that handles: +- Basic timeline frame ranges +- Source media frame ranges +- Retimed clip frame ranges """ from pprint import pformat +import opentimelineio as otio import pyblish.api - -try: - import opentimelineio as otio -except ImportError: - raise RuntimeError("OpenTimelineIO is not installed.") - from ayon_core.pipeline.editorial import ( get_media_range_with_retimes, otio_range_to_frame_range, @@ -38,16 +33,21 @@ def validate_otio_clip(instance, logger): return True -class CollectOtioFrameRanges(pyblish.api.InstancePlugin): - """Collect basic timeline frame ranges from OTIO clip. +class CollectOtioRanges(pyblish.api.InstancePlugin): + """Collect all OTIO-related frame ranges and timing information. - This plugin extracts and stores basic timeline frame ranges including - handles from the OTIO clip. + This plugin handles collection of: + - Basic timeline frame ranges with handles + - Source media frame ranges with handles + - Retimed clip frame ranges Requires: otioClip (otio.schema.Clip): OTIO clip object workfileFrameStart (int): Starting frame of work file + Optional: + shotDurationFromSource (int): Duration from source if retimed + Provides: frameStart (int): Start frame in timeline frameEnd (int): End frame in timeline @@ -55,24 +55,41 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): clipOut (int): Clip out point clipInH (int): Clip in point with handles clipOutH (int): Clip out point with handles + sourceStart (int): Source media start frame + sourceEnd (int): Source media end frame + sourceStartH (int): Source media start frame with handles + sourceEndH (int): Source media end frame with handles """ - label = "Collect OTIO Frame Ranges" + label = "Collect OTIO Ranges" order = pyblish.api.CollectorOrder - 0.08 families = ["shot", "clip"] - hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, instance): - """Process the instance to collect frame ranges. + """Process the instance to collect all frame ranges. Args: instance: The instance to process """ - if not validate_otio_clip(instance, self.log): return otio_clip = instance.data["otioClip"] + + # Collect timeline ranges if workfile start frame is available + if "workfileFrameStart" in instance.data: + self._collect_timeline_ranges(instance, otio_clip) + + # Collect source ranges if clip has available range + if hasattr(otio_clip, 'available_range') and otio_clip.available_range(): + self._collect_source_ranges(instance, otio_clip) + + # Handle retimed ranges if source duration is available + if "shotDurationFromSource" in instance.data: + self._collect_retimed_ranges(instance, otio_clip) + + def _collect_timeline_ranges(self, instance, otio_clip): + """Collect basic timeline frame ranges.""" workfile_start = instance.data["workfileFrameStart"] # Get timeline ranges @@ -98,40 +115,8 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): instance.data.update(data) self.log.debug(f"Added frame ranges: {pformat(data)}") - -class CollectOtioSourceRanges(pyblish.api.InstancePlugin): - """Collect source media frame ranges from OTIO clip. - - This plugin extracts and stores source media frame ranges including - handles from the OTIO clip. - - Requires: - otioClip (otio.schema.Clip): OTIO clip object - - Provides: - sourceStart (int): Source media start frame - sourceEnd (int): Source media end frame - sourceStartH (int): Source media start frame with handles - sourceEndH (int): Source media end frame with handles - """ - - label = "Collect Source OTIO Frame Ranges" - order = pyblish.api.CollectorOrder - 0.07 - families = ["shot", "clip"] - hosts = ["hiero", "flame"] - - def process(self, instance): - """Process the instance to collect source frame ranges. - - Args: - instance: The instance to process - """ - - if not validate_otio_clip(instance, self.log): - return - - otio_clip = instance.data["otioClip"] - + def _collect_source_ranges(self, instance, otio_clip): + """Collect source media frame ranges.""" # Get source ranges otio_src_range = otio_clip.source_range otio_available_range = otio_clip.available_range() @@ -156,41 +141,9 @@ class CollectOtioSourceRanges(pyblish.api.InstancePlugin): instance.data.update(data) self.log.debug(f"Added source ranges: {pformat(data)}") - -class CollectOtioRetimedRanges(pyblish.api.InstancePlugin): - """Update frame ranges for retimed clips. - - This plugin updates the frame end value for retimed clips. - - Requires: - otioClip (otio.schema.Clip): OTIO clip object - workfileFrameStart (int): Starting frame of work file - shotDurationFromSource (Optional[int]): Duration from source if retimed - - Provides: - frameEnd (int): Updated end frame for retimed clips - """ - - label = "Collect Retimed OTIO Frame Ranges" - order = pyblish.api.CollectorOrder - 0.06 - families = ["shot", "clip"] - hosts = ["hiero", "flame"] - - def process(self, instance): - """Process the instance to handle retimed clips. - - Args: - instance: The instance to process - """ - if not validate_otio_clip(instance, self.log): - return - + def _collect_retimed_ranges(self, instance, otio_clip): + """Handle retimed clip frame ranges.""" workfile_source_duration = instance.data.get("shotDurationFromSource") - if not workfile_source_duration: - self.log.debug("No source duration found, skipping retime handling.") - return - - otio_clip = instance.data["otioClip"] frame_start = instance.data["frameStart"] # Handle retimed clip frame range From be5eb08d2b6ff0ee9dae3592e8c157835e414b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 30 Jan 2025 10:57:21 +0100 Subject: [PATCH 06/49] Update client/ayon_core/plugins/publish/collect_otio_frame_ranges.py Co-authored-by: Robin De Lillo --- .../ayon_core/plugins/publish/collect_otio_frame_ranges.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 917b9ad206..5d306138eb 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -123,10 +123,7 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): otio_src_range_handles = otio_range_with_handles(otio_src_range, instance) # Get source available start frame - src_starting_from = otio.opentime.to_frames( - otio_available_range.start_time, - otio_available_range.start_time.rate - ) + src_starting_from = otio_available_range.to_frames() # Convert to frames src_start, src_end = otio_range_to_frame_range(otio_src_range) From f91baa0e1ebd17d171b23eaeac9096569654a71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 30 Jan 2025 10:58:04 +0100 Subject: [PATCH 07/49] Update client/ayon_core/plugins/publish/collect_otio_frame_ranges.py Co-authored-by: Robin De Lillo --- client/ayon_core/plugins/publish/collect_otio_frame_ranges.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 5d306138eb..0187858be8 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -101,8 +101,7 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles) frame_start = workfile_start - frame_end = frame_start + otio.opentime.to_frames( - otio_tl_range.duration, otio_tl_range.duration.rate) - 1 + frame_end = frame_start + otio_tl_range.duration.to_frames() - 1 data = { "frameStart": frame_start, From 888e81fac81f2bbe6fe759c9e34b0787aca26e4b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 30 Jan 2025 11:12:37 +0100 Subject: [PATCH 08/49] Fix OTIO frame range handling and add compatibility - Added backward compatibility for Hiero OTIO exporter. - Implemented rounding for floating rates to avoid precision issues. - Rescaled source ranges based on available range rates. - Updated calculations for source start, end, and handles. --- .../publish/collect_otio_frame_ranges.py | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 0187858be8..f93301d0f6 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -119,20 +119,46 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): # Get source ranges otio_src_range = otio_clip.source_range otio_available_range = otio_clip.available_range() - otio_src_range_handles = otio_range_with_handles(otio_src_range, instance) - # Get source available start frame - src_starting_from = otio_available_range.to_frames() - - # Convert to frames - src_start, src_end = otio_range_to_frame_range(otio_src_range) - src_start_h, src_end_h = otio_range_to_frame_range(otio_src_range_handles) + # Backward-compatibility for Hiero OTIO exporter. + # NTSC compatibility might introduce floating rates, when these are + # not exactly the same (23.976 vs 23.976024627685547) + # this will cause precision issue in computation. + # Currently round to 2 decimals for comparison, + # but this should always rescale after that. + rounded_av_rate = round(otio_available_range.start_time.rate, 2) + rounded_src_rate = round(otio_src_range.start_time.rate, 2) + if rounded_av_rate != rounded_src_rate: + conformed_src_in = otio_src_range.start_time.rescaled_to( + otio_available_range.start_time.rate + ) + conformed_src_duration = otio_src_range.duration.rescaled_to( + otio_available_range.duration.rate + ) + conformed_source_range = otio.opentime.TimeRange( + start_time=conformed_src_in, + duration=conformed_src_duration + ) + else: + conformed_source_range = otio_src_range + source_start = conformed_source_range.start_time + source_end = source_start + conformed_source_range.duration + handle_start = otio.opentime.RationalTime( + instance.data.get("handleStart", 0), + source_start.rate + ) + handle_end = otio.opentime.RationalTime( + instance.data.get("handleEnd", 0), + source_start.rate + ) + source_start_h = source_start - handle_start + source_end_h = source_end + handle_end data = { - "sourceStart": src_starting_from + src_start, - "sourceEnd": src_starting_from + src_end - 1, - "sourceStartH": src_starting_from + src_start_h, - "sourceEndH": src_starting_from + src_end_h - 1, + "sourceStart": source_start.to_frames(), + "sourceEnd": source_end.to_frames() - 1, + "sourceStartH": source_start_h.to_frames(), + "sourceEndH": source_end_h.to_frames() - 1, } instance.data.update(data) self.log.debug(f"Added source ranges: {pformat(data)}") From de82d8b60cb944148bba9047e89c04196de5658a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 30 Jan 2025 11:26:46 +0100 Subject: [PATCH 09/49] Add handling for clips without available ranges - Added a check for available range in OTIO clips. - Improved logging to inform when a clip has no available range. - Adjusted source range collection logic based on availability. --- .../plugins/publish/collect_otio_frame_ranges.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index f93301d0f6..34c12a1cf3 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -80,8 +80,18 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): if "workfileFrameStart" in instance.data: self._collect_timeline_ranges(instance, otio_clip) + has_available_range = False + # Traypublisher Simple or Advanced editorial publishing is + # working with otio clips which are having no available range + # because they are not having any media references. + try: + otio_clip.available_range() + has_available_range = True + except otio._otio.CannotComputeAvailableRangeError: + self.log.info("Clip has no available range") + # Collect source ranges if clip has available range - if hasattr(otio_clip, 'available_range') and otio_clip.available_range(): + if has_available_range: self._collect_source_ranges(instance, otio_clip) # Handle retimed ranges if source duration is available From 22769ef460a8e0a9c9972fc6a4fa90d4e7f2d549 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 30 Jan 2025 15:05:32 +0100 Subject: [PATCH 10/49] Refactor frame range handling for clarity - Improved readability by breaking long lines into multiple lines. - Removed unused variable related to shot duration from source. - Cleaned up code structure in the frame range collection process. --- .../ayon_core/plugins/publish/collect_otio_frame_ranges.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 34c12a1cf3..8a8af29ca9 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -104,11 +104,13 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): # Get timeline ranges otio_tl_range = otio_clip.range_in_parent() - otio_tl_range_handles = otio_range_with_handles(otio_tl_range, instance) + otio_tl_range_handles = otio_range_with_handles( + otio_tl_range, instance) # Convert to frames tl_start, tl_end = otio_range_to_frame_range(otio_tl_range) - tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles) + tl_start_h, tl_end_h = otio_range_to_frame_range( + otio_tl_range_handles) frame_start = workfile_start frame_end = frame_start + otio_tl_range.duration.to_frames() - 1 @@ -175,7 +177,6 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_retimed_ranges(self, instance, otio_clip): """Handle retimed clip frame ranges.""" - workfile_source_duration = instance.data.get("shotDurationFromSource") frame_start = instance.data["frameStart"] # Handle retimed clip frame range From 6bb4937c0aa7f36f872c04fc5d34c52fc4fa3fe8 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 11 Feb 2025 10:39:35 +0100 Subject: [PATCH 11/49] Consolidate frame range detection from collect_otio_frame_ranges plugin. --- .../publish/collect_otio_frame_ranges.py | 74 +- .../resources/timeline/timeline.json | 2054 +++++++++++++++++ .../test_collect_otio_frame_ranges.py | 129 ++ 3 files changed, 2234 insertions(+), 23 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json create mode 100644 tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 917b9ad206..f0192e473d 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -101,8 +101,7 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles) frame_start = workfile_start - frame_end = frame_start + otio.opentime.to_frames( - otio_tl_range.duration, otio_tl_range.duration.rate) - 1 + frame_end = frame_start + otio_tl_range.duration.to_frames() - 1 data = { "frameStart": frame_start, @@ -120,39 +119,68 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): # Get source ranges otio_src_range = otio_clip.source_range otio_available_range = otio_clip.available_range() - otio_src_range_handles = otio_range_with_handles(otio_src_range, instance) - # Get source available start frame - src_starting_from = otio.opentime.to_frames( - otio_available_range.start_time, - otio_available_range.start_time.rate + # Backward-compatibility for Hiero OTIO exporter. + # NTSC compatibility might introduce floating rates, when these are + # not exactly the same (23.976 vs 23.976024627685547) + # this will cause precision issue in computation. + # Currently round to 2 decimals for comparison, + # but this should always rescale after that. + rounded_av_rate = round(otio_available_range.start_time.rate, 2) + rounded_src_rate = round(otio_src_range.start_time.rate, 2) + if rounded_av_rate != rounded_src_rate: + conformed_src_in = otio_src_range.start_time.rescaled_to( + otio_available_range.start_time.rate + ) + conformed_src_duration = otio_src_range.duration.rescaled_to( + otio_available_range.duration.rate + ) + conformed_source_range = otio.opentime.TimeRange( + start_time=conformed_src_in, + duration=conformed_src_duration + ) + else: + conformed_source_range = otio_src_range + + source_start = conformed_source_range.start_time + source_end = source_start + conformed_source_range.duration + handle_start = otio.opentime.RationalTime( + instance.data.get("handleStart", 0), + source_start.rate ) - - # Convert to frames - src_start, src_end = otio_range_to_frame_range(otio_src_range) - src_start_h, src_end_h = otio_range_to_frame_range(otio_src_range_handles) - + handle_end = otio.opentime.RationalTime( + instance.data.get("handleEnd", 0), + source_start.rate + ) + source_start_h = source_start - handle_start + source_end_h = source_end + handle_end data = { - "sourceStart": src_starting_from + src_start, - "sourceEnd": src_starting_from + src_end - 1, - "sourceStartH": src_starting_from + src_start_h, - "sourceEndH": src_starting_from + src_end_h - 1, + "sourceStart": source_start.to_frames(), + "sourceEnd": source_end.to_frames() - 1, + "sourceStartH": source_start_h.to_frames(), + "sourceEndH": source_end_h.to_frames() - 1, } instance.data.update(data) self.log.debug(f"Added source ranges: {pformat(data)}") def _collect_retimed_ranges(self, instance, otio_clip): """Handle retimed clip frame ranges.""" - workfile_source_duration = instance.data.get("shotDurationFromSource") - frame_start = instance.data["frameStart"] - - # Handle retimed clip frame range retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) self.log.debug(f"Retimed attributes: {retimed_attributes}") + frame_start = instance.data["frameStart"] media_in = int(retimed_attributes["mediaIn"]) media_out = int(retimed_attributes["mediaOut"]) - frame_end = frame_start + (media_out - media_in) + 1 + frame_end = frame_start + (media_out - media_in) - instance.data["frameEnd"] = frame_end - self.log.debug(f"Updated frameEnd for retimed clip: {frame_end}") + data = { + "frameStart": frame_start, + "frameEnd": frame_end, + "sourceStart": media_in, + "sourceEnd": media_out, + "sourceStartH": media_in - int(retimed_attributes["handleStart"]), + "sourceEndH": media_out + int(retimed_attributes["handleEnd"]), + } + + instance.data.update(data) + self.log.debug(f"Updated retimed values: {data}") diff --git a/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json b/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json new file mode 100644 index 0000000000..03ed87569b --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json @@ -0,0 +1,2054 @@ +{ + "OTIO_SCHEMA": "Timeline.1", + "metadata": { + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "dt3", + "foundry.timeline.samplerate": "48000", + "openpype.project.lutSetting16Bit": "ACES - ACEScc", + "openpype.project.lutSetting8Bit": "Output - Rec.709", + "openpype.project.lutSettingFloat": "ACES - ACES2065-1", + "openpype.project.lutSettingLog": "ACES - ACEScc", + "openpype.project.lutSettingViewer": "ACES/Rec.709", + "openpype.project.lutSettingWorkingSpace": "ACES - ACEScg", + "openpype.project.lutUseOCIOForExport": true, + "openpype.project.ocioConfigName": "", + "openpype.project.ocioConfigPath": "C:/Program Files/Nuke12.2v3/plugins/OCIOConfigs/configs/aces_1.1/config.ocio", + "openpype.project.useOCIOEnvironmentOverride": true, + "openpype.timeline.height": 1080, + "openpype.timeline.pixelAspect": 1, + "openpype.timeline.width": 1920 + }, + "name": "sq001", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "metadata": {}, + "name": "tracks", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "reference", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referenceclip_mediash010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.08874841638 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86424.08877306872 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86476.08882648213 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86527.08887886834 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/sq01/referencesq01sh010\", \"task\": null, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\", \"hierarchy\": \"shots/sq01\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\"}, \"heroTrack\": true, \"uuid\": \"5fa79821-2a65-4f3e-aec5-05471b0f145e\", \"reviewTrack\": null, \"folderName\": \"referencesq01sh010\", \"label\": \"/shots/sq01/referencesq01sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"813286be-1492-47e2-aa4e-a192fdc4294e\", \"creator_attributes\": {\"fps\": \"from_selection\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1066, \"clipIn\": 127, \"clipOut\": 191, \"clipDuration\": 65, \"sourceIn\": 127.0, \"sourceOut\": 191.0}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateReference\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"reference\", \"folderPath\": \"/shots/sq01/referencesq01sh010\", \"task\": null, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\", \"hierarchy\": \"shots/sq01\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\"}, \"heroTrack\": true, \"uuid\": \"5fa79821-2a65-4f3e-aec5-05471b0f145e\", \"reviewTrack\": null, \"folderName\": \"referencesq01sh010\", \"parent_instance_id\": \"813286be-1492-47e2-aa4e-a192fdc4294e\", \"label\": \"/shots/sq01/referencesq01sh010 plateReference\", \"newHierarchyIntegration\": true, \"instance_id\": \"7a9ef903-ec0c-4c0c-9b84-5d5a8cf8e72c\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/referencesq01sh010 shotMain\", \"review\": false, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\"}", + "label": "AYONdata_1fa7d197", + "note": "AYON data container" + }, + "name": "AYONdata_1fa7d197", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86592.08894563509 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P01", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86535.08888708579 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "60", + "foundry.source.filename": "MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.filesize": "", + "foundry.source.fragments": "60", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01/MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "86531", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "60", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "8", + "media.exr.compressionName": "DWAA", + "media.exr.dataWindow": "0,0,1919,1079", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.dwaCompressionLevel": "90", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01/MER_sq001_sh010_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1217052", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1080", + "media.input.mtime": "2022-03-06 10:14:37", + "media.input.timecode": "01:00:05:11", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "ffffffffffffffff", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 60.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86531.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01\\", + "name_prefix": "MER_sq001_sh010_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 172800.17749683277 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + }, + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"ayon.create.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/test_align/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1052, \"clipIn\": 76, \"clipOut\": 126, \"clipDuration\": 51, \"sourceIn\": 0.0, \"sourceOut\": 50.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP01\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P01\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP01\", \"newHierarchyIntegration\": true, \"instance_id\": \"16ae41aa-20c4-4c63-97c6-7666e4d1d30b\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"reference\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.audio\": {\"id\": \"ayon.create.instance\", \"productType\": \"audio\", \"productName\": \"audioMain\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.audio\", \"variant\": \"main\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 audioMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"4f31e750-f665-4ef7-8fab-578ebc606d7e\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\"}", + "label": "AYONdata_86163a19", + "note": "AYON data container" + }, + "name": "AYONdata_86163a19", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "59", + "foundry.source.filename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.filesize": "", + "foundry.source.fragments": "59", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "172800", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "59", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "8", + "media.exr.compressionName": "DWAA", + "media.exr.dataWindow": "0,0,1919,1079", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.dwaCompressionLevel": "90", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1235182", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1080", + "media.input.mtime": "2022-03-06 10:14:41", + "media.input.timecode": "02:00:00:00", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "ffffffffffffffff", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 59.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 172800.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01\\", + "name_prefix": "MER_sq001_sh020_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "P01default_twsh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 345623.3550172907 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "name": "TimeWarp1", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1556, + "ayon.source.pixelAspect": 2.0, + "ayon.source.width": 1828, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "73", + "foundry.source.filename": "MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.filesize": "", + "foundry.source.fragments": "73", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1556", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01/MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.pixelAspect": "2", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "345619", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1828", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "73", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "9", + "media.exr.compressionName": "DWAB", + "media.exr.dataWindow": "0,0,1827,1555", + "media.exr.displayWindow": "0,0,1827,1555", + "media.exr.dwaCompressionLevel": "80", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "2", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:05", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01/MER_sq001_sh040_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1170604", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1556", + "media.input.mtime": "2022-03-30 13:47:47", + "media.input.timecode": "04:00:00:19", + "media.input.width": "1828", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "2a4", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 73.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 345619.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01\\", + "name_prefix": "MER_sq001_sh040_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P02", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 76.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 1.0000010271807451 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP02\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P02\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"881A3D65-A052-DC45-9D3B-304990BD6488\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P02\", \"shot\": \"sh020\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": false, \"uuid\": \"b7416739-1102-4dfd-bac3-771d43018b84\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP02\", \"newHierarchyIntegration\": true, \"instance_id\": \"6cc84e25-e2fa-4f31-9901-85d75e8fd36a\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"881A3D65-A052-DC45-9D3B-304990BD6488\"}", + "label": "AYONdata_05836436", + "note": "AYON data container" + }, + "name": "AYONdata_05836436", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACEScg", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 2048, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACEScg", + "foundry.source.duration": "200", + "foundry.source.filename": "MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.filesize": "", + "foundry.source.fragments": "200", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02/MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1", + "foundry.source.timecode": "1", + "foundry.source.umid": "bdfbe576-124a-4200-a1c9-daa2dcc3e952", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "2048", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACEScg", + "foundry.timeline.duration": "200", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAQAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "A:{1 0 1 1},B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "9", + "media.exr.compressionName": "DWAB", + "media.exr.dataWindow": "0,358,2047,904", + "media.exr.displayWindow": "0,0,2047,1079", + "media.exr.dwaCompressionLevel": "80", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02/MER_sq001_sh020_P02.0001.exr", + "media.input.filereader": "exr", + "media.input.filesize": "453070", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2022-03-30 11:29:25", + "media.input.width": "2048", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 200.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 1.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02\\", + "name_prefix": "MER_sq001_sh020_P02.", + "name_suffix": ".exr", + "start_frame": 1, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P03", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 76.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "img_sequence_exr", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 26.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 87311.69068479538 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP03\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P03\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"70A463D1-4FDD-E843-90D9-A2FC3978B06B\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P03\", \"shot\": \"sh030\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": false, \"uuid\": \"cd05c022-94ff-4527-bd3e-1533a8347f99\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP03\", \"newHierarchyIntegration\": true, \"instance_id\": \"fb5ea749-0f9b-43a0-b2b5-6dadc6f6af7e\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"70A463D1-4FDD-E843-90D9-A2FC3978B06B\"}", + "label": "AYONdata_0b6cdbd7", + "note": "AYON data container" + }, + "name": "AYONdata_0b6cdbd7", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 956, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "84", + "foundry.source.filename": "output.%04d.exr 1000-1083", + "foundry.source.filesize": "", + "foundry.source.fragments": "84", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc/output.%04d.exr 1000-1083", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1083", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "3cd0643b-4ee3-4d94-46dd-7aac61829c84", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "956", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "84", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "0,0,955,684", + "media.exr.displayWindow": "0,0,955,719", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2024-09-18 08:28:26", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc/output.1000.exr", + "media.input.filereader": "exr", + "media.input.filesize": "457525", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "720", + "media.input.mtime": "2024-09-18 08:28:26", + "media.input.timecode": "01:00:41:15", + "media.input.width": "956", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "f6b6ac187e7c550c", + "media.nuke.version": "15.0v5", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 84.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87399.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "Audio", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.08874841638 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86424.08877306872 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh020", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86476.08882648213 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86527.08887886834 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86592.08894563509 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Audio" + } + ] + } +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py new file mode 100644 index 0000000000..d895e9888f --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py @@ -0,0 +1,129 @@ +import os +import mock + +import opentimelineio as otio + +from ayon_core.plugins.publish import collect_otio_frame_ranges + + +_RESOURCE_DIR = os.path.join( + os.path.dirname(__file__), + "resources", + "timeline" +) + + +class MockInstance(): + """ Mock pyblish instance for testing purpose. + """ + def __init__(self, data: dict): + self.data = data + self.context = self + + +def _check_expected_frame_range_values( + clip_name: str, + expected_data: dict, + handle_start: int = 10, + handle_end: int = 10, + retimed: bool = False, +): + file_path = os.path.join(_RESOURCE_DIR, "timeline.json") + otio_timeline = otio.schema.Timeline.from_json_file(file_path) + + for otio_clip in otio_timeline.find_clips(): + if otio_clip.name == clip_name: + break + + instance_data = { + "otioClip": otio_clip, + "handleStart": handle_start, + "handleEnd": handle_end, + "workfileFrameStart": 1001, + } + if retimed: + instance_data["shotDurationFromSource"] = True + + instance = MockInstance(instance_data) + + processor = collect_otio_frame_ranges.CollectOtioRanges() + processor.process(instance) + + # Assert expected data is subset of edited instance. + assert expected_data.items() <= instance.data.items() + + +def test_movie_with_timecode(): + """ + Movie clip (with embedded timecode) + available_range = 86531-86590 23.976fps + source_range = 86535-86586 23.976fps + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1052, + 'clipIn': 24, + 'clipOut': 75, + 'clipInH': 14, + 'clipOutH': 85, + 'sourceStart': 86535, + 'sourceStartH': 86525, + 'sourceEnd': 86586, + 'sourceEndH': 86596, + } + + _check_expected_frame_range_values( + "sh010", + expected_data, + ) + + +def test_image_sequence(): + """ + EXR image sequence. + available_range = 87399-87482 24fps + source_range = 87311-87336 23.976fps + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1026, + 'clipIn': 76, + 'clipOut': 101, + 'clipInH': 66, + 'clipOutH': 111, + 'sourceStart': 87399, + 'sourceStartH': 87389, + 'sourceEnd': 87424, + 'sourceEndH': 87434, + } + + _check_expected_frame_range_values( + "img_sequence_exr", + expected_data, + ) + +def test_media_retimed(): + """ + EXR image sequence. + available_range = 345619-345691 23.976fps + source_range = 345623-345687 23.976fps + TimeWarp = frozen frame. + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1065, + 'clipIn': 127, + 'clipOut': 191, + 'clipInH': 117, + 'clipOutH': 201, + 'sourceStart': 1001, + 'sourceStartH': 1001, + 'sourceEnd': 1065, + 'sourceEndH': 1065, + } + + _check_expected_frame_range_values( + "P01default_twsh010", + expected_data, + retimed=True, + ) From d0364cbec3814d0b58ce2397b68628b91c442293 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 11 Feb 2025 10:47:12 +0100 Subject: [PATCH 12/49] Fix lint. --- .../ayon_core/plugins/publish/collect_otio_frame_ranges.py | 5 ++++- .../pipeline/editorial/test_collect_otio_frame_ranges.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 7dc4af273a..0a4efc2172 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -104,7 +104,10 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): # Get timeline ranges otio_tl_range = otio_clip.range_in_parent() - otio_tl_range_handles = otio_range_with_handles(otio_tl_range, instance) + otio_tl_range_handles = otio_range_with_handles( + otio_tl_range, + instance + ) # Convert to frames tl_start, tl_end = otio_range_to_frame_range(otio_tl_range) diff --git a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py index d895e9888f..20f0c05804 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py +++ b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py @@ -1,5 +1,4 @@ import os -import mock import opentimelineio as otio From daea1fae80b6f9df4dd88125531ee1a69e3900d3 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 17 Feb 2025 16:33:29 +0100 Subject: [PATCH 13/49] Add unit tests as part of CI/CD --- .github/workflows/pr_unittests.yaml | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/pr_unittests.yaml diff --git a/.github/workflows/pr_unittests.yaml b/.github/workflows/pr_unittests.yaml new file mode 100644 index 0000000000..a701180133 --- /dev/null +++ b/.github/workflows/pr_unittests.yaml @@ -0,0 +1,31 @@ +name: 📇 Code Linting + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number}} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + - name: Install requirements + run: ./tools/manage.sh create-env + - name: Run tests + run: ./tools/manage.sh run-tests From 5ee08a10dc64bf561cf6f1b811dd4f31d4630ea2 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 17 Feb 2025 16:34:32 +0100 Subject: [PATCH 14/49] Add unit tests as part of CI/CD --- .github/workflows/pr_unittests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_unittests.yaml b/.github/workflows/pr_unittests.yaml index a701180133..424135785c 100644 --- a/.github/workflows/pr_unittests.yaml +++ b/.github/workflows/pr_unittests.yaml @@ -1,4 +1,4 @@ -name: 📇 Code Linting +name: Run Unit Tests on: push: From 901ec4673824e23902e2212a142d96700903288c Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 17 Feb 2025 16:37:55 +0100 Subject: [PATCH 15/49] Change emoji. --- .github/workflows/pr_unittests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_unittests.yaml b/.github/workflows/pr_unittests.yaml index 424135785c..9dcb1f301f 100644 --- a/.github/workflows/pr_unittests.yaml +++ b/.github/workflows/pr_unittests.yaml @@ -1,4 +1,4 @@ -name: Run Unit Tests +name: 💯 Run Unit Tests on: push: From ac93b5a34b9c6d8a23fd741fbba356f575ea8ef3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 14:36:21 +0100 Subject: [PATCH 16/49] multiselect combobox enhancements --- .../tools/loader/ui/_multicombobox.py | 381 ++++++++++++++++-- .../tools/loader/ui/statuses_combo.py | 234 ++--------- 2 files changed, 381 insertions(+), 234 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 9efe57ef0f..393272fdf9 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -1,7 +1,10 @@ +from __future__ import annotations +import typing from typing import List, Tuple, Optional, Iterable, Any from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.utils.lib import ( checkstate_int_to_enum, checkstate_enum_to_int, @@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import ( UNCHECKED_INT, ITEM_IS_USER_TRISTATE, ) +if typing.TYPE_CHECKING: + from ayon_core.tools.loader.abstract import FrontendLoaderController VALUE_ITEM_TYPE = 0 STANDARD_ITEM_TYPE = 1 SEPARATOR_ITEM_TYPE = 2 +VALUE_ITEM_SUBTYPE = 0 +SELECT_ALL_SUBTYPE = 1 +DESELECT_ALL_SUBTYPE = 2 +SWAP_STATE_SUBTYPE = 3 + + +class BaseQtModel(QtGui.QStandardItemModel): + _empty_icon = None + + def __init__( + self, + item_type_role: int, + item_subtype_role: int, + empty_values_label: str, + controller: FrontendLoaderController, + ): + self._item_type_role = item_type_role + self._item_subtype_role = item_subtype_role + self._empty_values_label = empty_values_label + self._controller = controller + + self._last_project = None + + self._select_project_item = None + self._empty_values_item = None + + self._select_all_item = None + self._deselect_all_item = None + self._swap_states_item = None + + super().__init__() + + self.refresh(None) + + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + raise NotImplementedError( + "'_get_standard_items' is not implemented" + f" for {self.__class__}" + ) + + def _clear_standard_items(self): + raise NotImplementedError( + "'_clear_standard_items' is not implemented" + f" for {self.__class__}" + ) + + def _prepare_new_value_items( + self, project_name: str, project_changed: bool + ) -> tuple[ + list[QtGui.QStandardItem], list[QtGui.QStandardItem] + ]: + raise NotImplementedError( + "'_prepare_new_value_items' is not implemented" + f" for {self.__class__}" + ) + + def refresh(self, project_name: Optional[str]): + # New project was selected + project_changed = False + if project_name != self._last_project: + self._last_project = project_name + project_changed = True + + if project_name is None: + self._add_select_project_item() + return + + value_items, items_to_remove = self._prepare_new_value_items( + project_name, project_changed + ) + if not value_items: + self._add_empty_values_item() + return + + self._remove_empty_items() + + root_item = self.invisibleRootItem() + for row_idx, value_item in enumerate(value_items): + if value_item.row() == row_idx: + continue + if value_item.row() >= 0: + root_item.takeRow(value_item.row()) + root_item.insertRow(row_idx, value_item) + + for item in items_to_remove: + root_item.removeRow(item.row()) + + self._add_selection_items() + + def setData(self, index, value, role): + if role == QtCore.Qt.CheckStateRole and index.isValid(): + item_subtype = index.data(self._item_subtype_role) + if item_subtype == SELECT_ALL_SUBTYPE: + for item in self._get_standard_items(): + item.setCheckState(QtCore.Qt.Checked) + return True + if item_subtype == DESELECT_ALL_SUBTYPE: + for item in self._get_standard_items(): + item.setCheckState(QtCore.Qt.Unchecked) + return True + if item_subtype == SWAP_STATE_SUBTYPE: + for item in self._get_standard_items(): + current_state = item.checkState() + item.setCheckState( + QtCore.Qt.Checked + if current_state == QtCore.Qt.Unchecked + else QtCore.Qt.Unchecked + ) + return True + return super().setData(index, value, role) + + @classmethod + def _get_empty_icon(cls): + if cls._empty_icon is None: + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + cls._empty_icon = QtGui.QIcon(pix) + return cls._empty_icon + + def _init_default_items(self): + if self._empty_values_item is not None: + return + + empty_values_item = QtGui.QStandardItem(self._empty_values_label) + select_project_item = QtGui.QStandardItem("Select project...") + + select_all_item = QtGui.QStandardItem("Select all") + deselect_all_item = QtGui.QStandardItem("Deselect all") + swap_states_item = QtGui.QStandardItem("Swap") + + for item in ( + empty_values_item, + select_project_item, + select_all_item, + deselect_all_item, + swap_states_item, + ): + item.setData(STANDARD_ITEM_TYPE, self._item_type_role) + + select_all_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "done_all", + "color": "white" + })) + deselect_all_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "remove_done", + "color": "white" + })) + swap_states_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "swap_horiz", + "color": "white" + })) + + for item in ( + empty_values_item, + select_project_item, + ): + item.setFlags(QtCore.Qt.NoItemFlags) + + for item, item_type in ( + (select_all_item, SELECT_ALL_SUBTYPE), + (deselect_all_item, DESELECT_ALL_SUBTYPE), + (swap_states_item, SWAP_STATE_SUBTYPE), + ): + item.setData(item_type, self._item_subtype_role) + + for item in ( + select_all_item, + deselect_all_item, + swap_states_item, + ): + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + + self._empty_values_item = empty_values_item + self._select_project_item = select_project_item + + self._select_all_item = select_all_item + self._deselect_all_item = deselect_all_item + self._swap_states_item = swap_states_item + + def _get_empty_values_item(self): + self._init_default_items() + return self._empty_values_item + + def _get_select_project_item(self): + self._init_default_items() + return self._select_project_item + + def _get_empty_items(self): + self._init_default_items() + return [ + self._empty_values_item, + self._select_project_item, + ] + + def _get_selection_items(self): + self._init_default_items() + return [ + self._select_all_item, + self._deselect_all_item, + self._swap_states_item, + ] + + def _get_default_items(self): + return self._get_empty_items() + self._get_selection_items() + + def _add_select_project_item(self): + item = self._get_select_project_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_empty_values_item(self): + item = self._get_empty_values_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_selection_items(self): + root_item = self.invisibleRootItem() + items = self._get_selection_items() + for item in self._get_selection_items(): + row = item.row() + if row >= 0: + root_item.takeRow(row) + root_item.appendRows(items) + + def _remove_items(self): + root_item = self.invisibleRootItem() + for item in self._get_default_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + + root_item.removeRows(0, root_item.rowCount()) + self._clear_standard_items() + + def _remove_empty_items(self): + root_item = self.invisibleRootItem() + for item in self._get_empty_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): """Delegate showing status name and short name.""" + _empty_icon = None _checked_value = checkstate_enum_to_int(QtCore.Qt.Checked) _checked_bg_color = QtGui.QColor("#2C3B4C") @@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): self._icon_role = icon_role self._item_type_role = item_type_role + @classmethod + def _get_empty_icon(cls): + if cls._empty_icon is None: + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + cls._empty_icon = QtGui.QIcon(pix) + return cls._empty_icon + def paint(self, painter, option, index): item_type = None if self._item_type_role is not None: @@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): if option.state & QtWidgets.QStyle.State_Open: state = QtGui.QIcon.On icon = self._get_index_icon(index) + if icon is None or icon.isNull(): + icon = self._get_empty_icon() + option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration # Disable visible check indicator @@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtCore.Qt.Key_Home, QtCore.Qt.Key_End, } + _top_bottom_margins = 1 + _top_bottom_padding = 2 + _left_right_padding = 3 + _item_bg_color = QtGui.QColor("#31424e") def __init__( self, @@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): idxs = self._get_checked_idx() # draw the icon and text - draw_text = True + draw_items = False combotext = None if self._custom_text is not None: combotext = self._custom_text elif not idxs: combotext = self._placeholder_text else: - draw_text = False + draw_items = True content_field_rect = self.style().subControlRect( QtWidgets.QStyle.CC_ComboBox, @@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtWidgets.QStyle.SC_ComboBoxEditField ).adjusted(1, 0, -1, 0) - if draw_text: + if draw_items: + self._paint_items(painter, idxs, content_field_rect) + else: color = option.palette.color(QtGui.QPalette.Text) color.setAlpha(67) pen = painter.pen() @@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, combotext ) - else: - self._paint_items(painter, idxs, content_field_rect) painter.end() def _paint_items(self, painter, indexes, content_rect): origin_rect = QtCore.QRect(content_rect) - metrics = self.fontMetrics() model = self.model() available_width = content_rect.width() total_used_width = 0 @@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): continue icon = index.data(self._icon_role) - # TODO handle this case - if icon is None or icon.isNull(): - continue + text = index.data(self._text_role) + valid_icon = icon is not None and not icon.isNull() + if valid_icon: + sizes = icon.availableSizes() + if sizes: + valid_icon = any(size.width() > 1 for size in sizes) - icon_rect = QtCore.QRect(content_rect) - diff = icon_rect.height() - metrics.height() - if diff < 0: - diff = 0 - top_offset = diff // 2 - bottom_offset = diff - top_offset - icon_rect.adjust(0, top_offset, 0, -bottom_offset) - icon_rect.setWidth(metrics.height()) - icon.paint( - painter, - icon_rect, - QtCore.Qt.AlignCenter, - QtGui.QIcon.Normal, - QtGui.QIcon.On - ) - content_rect.setLeft(icon_rect.right() + spacing) - if total_used_width > 0: - total_used_width += spacing - total_used_width += icon_rect.width() - if total_used_width > available_width: - break + if valid_icon: + metrics = self.fontMetrics() + icon_rect = QtCore.QRect(content_rect) + diff = icon_rect.height() - metrics.height() + if diff < 0: + diff = 0 + top_offset = diff // 2 + bottom_offset = diff - top_offset + icon_rect.adjust(0, top_offset, 0, -bottom_offset) + used_width = metrics.height() + if total_used_width > 0: + total_used_width += spacing + total_used_width += used_width + if total_used_width > available_width: + break + + icon_rect.setWidth(used_width) + icon.paint( + painter, + icon_rect, + QtCore.Qt.AlignCenter, + QtGui.QIcon.Normal, + QtGui.QIcon.On + ) + content_rect.setLeft(icon_rect.right() + spacing) + + elif text: + bg_height = ( + content_rect.height() + - (2 * self._top_bottom_margins) + ) + font_height = bg_height - (2 * self._top_bottom_padding) + + bg_top_y = content_rect.y() + self._top_bottom_margins + + font = self.font() + font.setPixelSize(font_height) + metrics = QtGui.QFontMetrics(font) + painter.setFont(font) + + label_rect = metrics.boundingRect(text) + + bg_width = label_rect.width() + (2 * self._left_right_padding) + if total_used_width > 0: + total_used_width += spacing + total_used_width += bg_width + if total_used_width > available_width: + break + + bg_rect = QtCore.QRectF(label_rect) + bg_rect.moveTop(bg_top_y) + bg_rect.moveLeft(content_rect.left()) + bg_rect.setWidth(bg_width) + bg_rect.setHeight(bg_height) + + label_rect.moveTop(bg_top_y) + label_rect.moveLeft( + content_rect.left() + self._left_right_padding + ) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self._item_bg_color) + painter.drawText(label_rect, QtCore.Qt.AlignCenter, text) + + content_rect.setLeft(bg_rect.right() + spacing) painter.restore() diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 9fe7ab62a5..2f034d00de 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -1,4 +1,4 @@ -from typing import List, Dict +from __future__ import annotations from qtpy import QtCore, QtGui @@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem from ._multicombobox import ( CustomPaintMultiselectComboBox, - STANDARD_ITEM_TYPE, + BaseQtModel, ) STATUS_ITEM_TYPE = 0 @@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 -class StatusesQtModel(QtGui.QStandardItemModel): +class StatusesQtModel(BaseQtModel): def __init__(self, controller): - self._controller = controller - self._items_by_name: Dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {} - self._last_project = None + self._items_by_name: dict[str, QtGui.QStandardItem] = {} + self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} + super().__init__( + ITEM_TYPE_ROLE, + ITEM_SUBTYPE_ROLE, + "No statuses...", + controller, + ) - self._select_project_item = None - self._empty_statuses_item = None + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + return list(self._items_by_name.values()) - self._select_all_item = None - self._deselect_all_item = None - self._swap_states_item = None + def _clear_standard_items(self): + self._items_by_name.clear() - super().__init__() - - self.refresh(None) - - def get_placeholder_text(self): - return self._placeholder - - def refresh(self, project_name): - # New project was selected - # status filter is reset to show all statuses - uncheck_all = False - if project_name != self._last_project: - self._last_project = project_name - uncheck_all = True - - if project_name is None: - self._add_select_project_item() - return - - status_items: List[StatusItem] = ( + def _prepare_new_value_items( + self, project_name: str, project_changed: bool + ): + status_items: list[StatusItem] = ( self._controller.get_project_status_items( project_name, sender=STATUSES_FILTER_SENDER ) ) + items = [] + items_to_remove = [] if not status_items: - self._add_empty_statuses_item() - return + return items, items_to_remove - self._remove_empty_items() - - items_to_remove = set(self._items_by_name) - root_item = self.invisibleRootItem() + names_to_remove = set(self._items_by_name) for row_idx, status_item in enumerate(status_items): name = status_item.name if name in self._items_by_name: - is_new = False item = self._items_by_name[name] - if uncheck_all: - item.setCheckState(QtCore.Qt.Unchecked) - items_to_remove.discard(name) + names_to_remove.discard(name) else: - is_new = True item = QtGui.QStandardItem() item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) item.setCheckState(QtCore.Qt.Unchecked) @@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel): if item.data(role) != value: item.setData(value, role) - if is_new: - root_item.insertRow(row_idx, item) + if project_changed: + item.setCheckState(QtCore.Qt.Unchecked) + items.append(item) - for name in items_to_remove: - item = self._items_by_name.pop(name) - root_item.removeRow(item.row()) + for name in names_to_remove: + items_to_remove.append(self._items_by_name.pop(name)) - self._add_selection_items() - - def setData(self, index, value, role): - if role == QtCore.Qt.CheckStateRole and index.isValid(): - item_type = index.data(ITEM_SUBTYPE_ROLE) - if item_type == SELECT_ALL_TYPE: - for item in self._items_by_name.values(): - item.setCheckState(QtCore.Qt.Checked) - return True - if item_type == DESELECT_ALL_TYPE: - for item in self._items_by_name.values(): - item.setCheckState(QtCore.Qt.Unchecked) - return True - if item_type == SWAP_STATE_TYPE: - for item in self._items_by_name.values(): - current_state = item.checkState() - item.setCheckState( - QtCore.Qt.Checked - if current_state == QtCore.Qt.Unchecked - else QtCore.Qt.Unchecked - ) - return True - return super().setData(index, value, role) + return items, items_to_remove def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: name = status_item.name @@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel): self._icons_by_name_n_color[unique_id] = icon return icon - def _init_default_items(self): - if self._empty_statuses_item is not None: - return - - empty_statuses_item = QtGui.QStandardItem("No statuses...") - select_project_item = QtGui.QStandardItem("Select project...") - - select_all_item = QtGui.QStandardItem("Select all") - deselect_all_item = QtGui.QStandardItem("Deselect all") - swap_states_item = QtGui.QStandardItem("Swap") - - for item in ( - empty_statuses_item, - select_project_item, - select_all_item, - deselect_all_item, - swap_states_item, - ): - item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE) - - select_all_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "done_all", - "color": "white" - })) - deselect_all_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "remove_done", - "color": "white" - })) - swap_states_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "swap_horiz", - "color": "white" - })) - - for item in ( - empty_statuses_item, - select_project_item, - ): - item.setFlags(QtCore.Qt.NoItemFlags) - - for item, item_type in ( - (select_all_item, SELECT_ALL_TYPE), - (deselect_all_item, DESELECT_ALL_TYPE), - (swap_states_item, SWAP_STATE_TYPE), - ): - item.setData(item_type, ITEM_SUBTYPE_ROLE) - - for item in ( - select_all_item, - deselect_all_item, - swap_states_item, - ): - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - - self._empty_statuses_item = empty_statuses_item - self._select_project_item = select_project_item - - self._select_all_item = select_all_item - self._deselect_all_item = deselect_all_item - self._swap_states_item = swap_states_item - - def _get_empty_statuses_item(self): - self._init_default_items() - return self._empty_statuses_item - - def _get_select_project_item(self): - self._init_default_items() - return self._select_project_item - - def _get_empty_items(self): - self._init_default_items() - return [ - self._empty_statuses_item, - self._select_project_item, - ] - - def _get_selection_items(self): - self._init_default_items() - return [ - self._select_all_item, - self._deselect_all_item, - self._swap_states_item, - ] - - def _get_default_items(self): - return self._get_empty_items() + self._get_selection_items() - - def _add_select_project_item(self): - item = self._get_select_project_item() - if item.row() < 0: - self._remove_items() - root_item = self.invisibleRootItem() - root_item.appendRow(item) - - def _add_empty_statuses_item(self): - item = self._get_empty_statuses_item() - if item.row() < 0: - self._remove_items() - root_item = self.invisibleRootItem() - root_item.appendRow(item) - - def _add_selection_items(self): - root_item = self.invisibleRootItem() - items = self._get_selection_items() - for item in self._get_selection_items(): - row = item.row() - if row >= 0: - root_item.takeRow(row) - root_item.appendRows(items) - - def _remove_items(self): - root_item = self.invisibleRootItem() - for item in self._get_default_items(): - if item.row() < 0: - continue - root_item.takeRow(item.row()) - - root_item.removeRows(0, root_item.rowCount()) - self._items_by_name.clear() - - def _remove_empty_items(self): - root_item = self.invisibleRootItem() - for item in self._get_empty_items(): - if item.row() < 0: - continue - root_item.takeRow(item.row()) - class StatusesCombobox(CustomPaintMultiselectComboBox): def __init__(self, controller, parent): From 5317d2817df9f2a2ecedf5b7565a069fb0e0ef94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 14:37:08 +0100 Subject: [PATCH 17/49] converted product types view to combobox --- .../tools/loader/ui/product_types_widget.py | 278 ++++++------------ .../tools/loader/ui/products_widget.py | 42 +-- client/ayon_core/tools/loader/ui/window.py | 36 ++- 3 files changed, 136 insertions(+), 220 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/product_types_widget.py b/client/ayon_core/tools/loader/ui/product_types_widget.py index 9b1bf6326f..ff2a70a7fa 100644 --- a/client/ayon_core/tools/loader/ui/product_types_widget.py +++ b/client/ayon_core/tools/loader/ui/product_types_widget.py @@ -1,57 +1,66 @@ from qtpy import QtWidgets, QtGui, QtCore -from ayon_core.tools.utils import get_qt_icon +from ._multicombobox import ( + CustomPaintMultiselectComboBox, + BaseQtModel, +) + +STATUS_ITEM_TYPE = 0 +SELECT_ALL_TYPE = 1 +DESELECT_ALL_TYPE = 2 +SWAP_STATE_TYPE = 3 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 +ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 -class ProductTypesQtModel(QtGui.QStandardItemModel): - refreshed = QtCore.Signal() - filter_changed = QtCore.Signal() - +class ProductTypesQtModel(BaseQtModel): def __init__(self, controller): - super(ProductTypesQtModel, self).__init__() - self._controller = controller - self._reset_filters_on_refresh = True self._refreshing = False self._bulk_change = False - self._last_project = None self._items_by_name = {} - controller.register_event_callback( - "controller.reset.finished", - self._on_controller_reset_finish, + super().__init__( + item_type_role=ITEM_TYPE_ROLE, + item_subtype_role=ITEM_SUBTYPE_ROLE, + empty_values_label="No product types...", + controller=controller, ) def is_refreshing(self): return self._refreshing - def get_filter_info(self): - """Product types filtering info. - - Returns: - dict[str, bool]: Filtering value by product type name. False value - means to hide product type. - """ - - return { - name: item.checkState() == QtCore.Qt.Checked - for name, item in self._items_by_name.items() - } - def refresh(self, project_name): self._refreshing = True + super().refresh(project_name) + + self._reset_filters_on_refresh = False + self._refreshing = False + + def reset_product_types_filter_on_refresh(self): + self._reset_filters_on_refresh = True + + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + return list(self._items_by_name.values()) + + def _clear_standard_items(self): + self._items_by_name.clear() + + def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ + list[QtGui.QStandardItem], list[QtGui.QStandardItem] + ]: product_type_items = self._controller.get_product_type_items( project_name) self._last_project = project_name - items_to_remove = set(self._items_by_name.keys()) - new_items = [] + names_to_remove = set(self._items_by_name.keys()) + items = [] items_filter_required = {} for product_type_item in product_type_items: name = product_type_item.name - items_to_remove.discard(name) + names_to_remove.discard(name) item = self._items_by_name.get(name) # Apply filter to new items or if filters reset is requested filter_required = self._reset_filters_on_refresh @@ -61,15 +70,13 @@ class ProductTypesQtModel(QtGui.QStandardItemModel): item.setData(name, PRODUCT_TYPE_ROLE) item.setEditable(False) item.setCheckable(True) - new_items.append(item) self._items_by_name[name] = item + items.append(item) + if filter_required: items_filter_required[name] = item - icon = get_qt_icon(product_type_item.icon) - item.setData(icon, QtCore.Qt.DecorationRole) - if items_filter_required: product_types_filter = self._controller.get_product_types_filter() for product_type, item in items_filter_required.items(): @@ -77,180 +84,77 @@ class ProductTypesQtModel(QtGui.QStandardItemModel): int(product_type in product_types_filter.product_types) + int(product_types_filter.is_allow_list) ) - state = ( + item.setCheckState( QtCore.Qt.Checked if matching % 2 == 0 else QtCore.Qt.Unchecked ) - item.setCheckState(state) - root_item = self.invisibleRootItem() - if new_items: - root_item.appendRows(new_items) + items_to_remove = [] + for name in names_to_remove: + items_to_remove.append( + self._items_by_name.pop(name) + ) - for name in items_to_remove: - item = self._items_by_name.pop(name) - root_item.removeRow(item.row()) + # Uncheck all if all are checked (same result) + if all( + item.checkState() == QtCore.Qt.Checked + for item in items + ): + for item in items: + item.setCheckState(QtCore.Qt.Unchecked) - self._reset_filters_on_refresh = False - self._refreshing = False - self.refreshed.emit() - - def reset_product_types_filter_on_refresh(self): - self._reset_filters_on_refresh = True - - def setData(self, index, value, role=None): - checkstate_changed = False - if role is None: - role = QtCore.Qt.EditRole - elif role == QtCore.Qt.CheckStateRole: - checkstate_changed = True - output = super(ProductTypesQtModel, self).setData(index, value, role) - if checkstate_changed and not self._bulk_change: - self.filter_changed.emit() - return output - - def change_state_for_all(self, checked): - if self._items_by_name: - self.change_states(checked, self._items_by_name.keys()) - - def change_states(self, checked, product_types): - product_types = set(product_types) - if not product_types: - return - - if checked is None: - state = None - elif checked: - state = QtCore.Qt.Checked - else: - state = QtCore.Qt.Unchecked - - self._bulk_change = True - - changed = False - for product_type in product_types: - item = self._items_by_name.get(product_type) - if item is None: - continue - new_state = state - item_checkstate = item.checkState() - if new_state is None: - if item_checkstate == QtCore.Qt.Checked: - new_state = QtCore.Qt.Unchecked - else: - new_state = QtCore.Qt.Checked - elif item_checkstate == new_state: - continue - changed = True - item.setCheckState(new_state) - - self._bulk_change = False - - if changed: - self.filter_changed.emit() - - def _on_controller_reset_finish(self): - self.refresh(self._last_project) + return items, items_to_remove -class ProductTypesView(QtWidgets.QListView): - filter_changed = QtCore.Signal() - +class ProductTypesCombobox(CustomPaintMultiselectComboBox): def __init__(self, controller, parent): - super(ProductTypesView, self).__init__(parent) - - self.setSelectionMode( - QtWidgets.QAbstractItemView.ExtendedSelection + self._controller = controller + model = ProductTypesQtModel(controller) + super().__init__( + PRODUCT_TYPE_ROLE, + PRODUCT_TYPE_ROLE, + QtCore.Qt.ForegroundRole, + QtCore.Qt.DecorationRole, + item_type_role=ITEM_TYPE_ROLE, + model=model, + parent=parent ) - self.setAlternatingRowColors(True) - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - product_types_model = ProductTypesQtModel(controller) - product_types_proxy_model = QtCore.QSortFilterProxyModel() - product_types_proxy_model.setSourceModel(product_types_model) - - self.setModel(product_types_proxy_model) - - product_types_model.refreshed.connect(self._on_refresh_finished) - product_types_model.filter_changed.connect(self._on_filter_change) - self.customContextMenuRequested.connect(self._on_context_menu) + self.set_placeholder_text("Product types filter...") + self._model = model + self._last_project_name = None + self._fully_disabled_filter = False controller.register_event_callback( "selection.project.changed", self._on_project_change ) - - self._controller = controller - self._refresh_product_types_filter = False - - self._product_types_model = product_types_model - self._product_types_proxy_model = product_types_proxy_model - - def get_filter_info(self): - return self._product_types_model.get_filter_info() + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh + ) + self.setToolTip("Product types filter") + self.value_changed.connect( + self._on_product_type_filter_change + ) def reset_product_types_filter_on_refresh(self): - self._product_types_model.reset_product_types_filter_on_refresh() + self._model.reset_product_types_filter_on_refresh() + + def _on_product_type_filter_change(self): + lines = ["Product types filter"] + for item in self.get_value_info(): + status_name, enabled = item + lines.append(f"{'✔' if enabled else '☐'} {status_name}") + + self.setToolTip("\n".join(lines)) def _on_project_change(self, event): project_name = event["project_name"] - self._product_types_model.refresh(project_name) + self._last_project_name = project_name + self._model.refresh(project_name) - def _on_refresh_finished(self): - # Apply product types filter on first show - self.filter_changed.emit() - - def _on_filter_change(self): - if not self._product_types_model.is_refreshing(): - self.filter_changed.emit() - - def _change_selection_state(self, checkstate): - selection_model = self.selectionModel() - product_types = { - index.data(PRODUCT_TYPE_ROLE) - for index in selection_model.selectedIndexes() - } - product_types.discard(None) - self._product_types_model.change_states(checkstate, product_types) - - def _on_enable_all(self): - self._product_types_model.change_state_for_all(True) - - def _on_disable_all(self): - self._product_types_model.change_state_for_all(False) - - def _on_context_menu(self, pos): - menu = QtWidgets.QMenu(self) - - # Add enable all action - action_check_all = QtWidgets.QAction(menu) - action_check_all.setText("Enable All") - action_check_all.triggered.connect(self._on_enable_all) - # Add disable all action - action_uncheck_all = QtWidgets.QAction(menu) - action_uncheck_all.setText("Disable All") - action_uncheck_all.triggered.connect(self._on_disable_all) - - menu.addAction(action_check_all) - menu.addAction(action_uncheck_all) - - # Get mouse position - global_pos = self.viewport().mapToGlobal(pos) - menu.exec_(global_pos) - - def event(self, event): - if event.type() == QtCore.QEvent.KeyPress: - if event.key() == QtCore.Qt.Key_Space: - self._change_selection_state(None) - return True - - if event.key() == QtCore.Qt.Key_Backspace: - self._change_selection_state(False) - return True - - if event.key() == QtCore.Qt.Key_Return: - self._change_selection_state(True) - return True - - return super(ProductTypesView, self).event(event) + def _on_projects_refresh(self): + if self._last_project_name: + self._model.refresh(self._last_project_name) + self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 748a1b5fb8..41a49b8ae1 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -1,4 +1,6 @@ +from __future__ import annotations import collections +from typing import Optional from qtpy import QtWidgets, QtCore @@ -36,7 +38,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): def __init__(self, parent=None): super().__init__(parent) - self._product_type_filters = {} + self._product_type_filters = None self._statuses_filter = None self._ascending_sort = True @@ -46,6 +48,8 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return set(self._statuses_filter) def set_product_type_filters(self, product_type_filters): + if self._product_type_filters == product_type_filters: + return self._product_type_filters = product_type_filters self.invalidateFilter() @@ -59,28 +63,32 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) - product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) - product_types = [] - if product_types_s: - product_types = product_types_s.split("|") - - for product_type in product_types: - if not self._product_type_filters.get(product_type, True): - return False - - if not self._accept_row_by_statuses(index): + if not self._accept_row_by_role_value( + index, self._product_type_filters, PRODUCT_TYPE_ROLE + ): return False + + if not self._accept_row_by_role_value( + index, self._statuses_filter, STATUS_NAME_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) - def _accept_row_by_statuses(self, index): - if self._statuses_filter is None: + def _accept_row_by_role_value( + self, + index: QtCore.QModelIndex, + filter_value: Optional[set[str]], + role: int + ): + if filter_value is None: return True - if not self._statuses_filter: + if not filter_value: return False - status_s = index.data(STATUS_NAME_FILTER_ROLE) + status_s = index.data(role) for status in status_s.split("|"): - if status in self._statuses_filter: + if status in filter_value: return True return False @@ -120,7 +128,7 @@ class ProductsWidget(QtWidgets.QWidget): 90, # Product type 130, # Folder label 60, # Version - 100, # Status + 100, # Status 125, # Time 75, # Author 75, # Frames diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 31c9908b23..31d88d4ed7 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -15,7 +15,7 @@ from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .products_widget import ProductsWidget -from .product_types_widget import ProductTypesView +from .product_types_widget import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget @@ -164,8 +164,6 @@ class LoaderWindow(QtWidgets.QWidget): folders_widget = LoaderFoldersWidget(controller, context_widget) - product_types_widget = ProductTypesView(controller, context_splitter) - context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(context_top_widget, 0) @@ -173,7 +171,6 @@ class LoaderWindow(QtWidgets.QWidget): context_layout.addWidget(folders_widget, 1) context_splitter.addWidget(context_widget) - context_splitter.addWidget(product_types_widget) context_splitter.setStretchFactor(0, 65) context_splitter.setStretchFactor(1, 35) @@ -185,6 +182,10 @@ class LoaderWindow(QtWidgets.QWidget): products_filter_input = PlaceholderLineEdit(products_inputs_widget) products_filter_input.setPlaceholderText("Product name filter...") + product_types_filter_combo = ProductTypesCombobox( + controller, products_inputs_widget + ) + product_status_filter_combo = StatusesCombobox(controller, self) product_group_checkbox = QtWidgets.QCheckBox( @@ -196,6 +197,7 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_types_filter_combo, 1) products_inputs_layout.addWidget(product_status_filter_combo, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) @@ -244,12 +246,12 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - product_types_widget.filter_changed.connect( - self._on_product_type_filter_change - ) products_filter_input.textChanged.connect( self._on_product_filter_change ) + product_types_filter_combo.value_changed.connect( + self._on_product_type_filter_change + ) product_status_filter_combo.value_changed.connect( self._on_status_filter_change ) @@ -304,9 +306,8 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_filter_input = folders_filter_input self._folders_widget = folders_widget - self._product_types_widget = product_types_widget - self._products_filter_input = products_filter_input + self._product_types_filter_combo = product_types_filter_combo self._product_status_filter_combo = product_status_filter_combo self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -335,7 +336,7 @@ class LoaderWindow(QtWidgets.QWidget): self._controller.reset() def showEvent(self, event): - super(LoaderWindow, self).showEvent(event) + super().showEvent(event) if self._first_show: self._on_first_show() @@ -343,9 +344,13 @@ class LoaderWindow(QtWidgets.QWidget): self._show_timer.start() def closeEvent(self, event): - super(LoaderWindow, self).closeEvent(event) + super().closeEvent(event) - self._product_types_widget.reset_product_types_filter_on_refresh() + ( + self + ._product_types_filter_combo + .reset_product_types_filter_on_refresh() + ) self._reset_on_show = True @@ -363,7 +368,7 @@ class LoaderWindow(QtWidgets.QWidget): event.setAccepted(True) return - super(LoaderWindow, self).keyPressEvent(event) + super().keyPressEvent(event) def _on_first_show(self): self._first_show = False @@ -428,9 +433,8 @@ class LoaderWindow(QtWidgets.QWidget): self._products_widget.set_statuses_filter(status_names) def _on_product_type_filter_change(self): - self._products_widget.set_product_type_filter( - self._product_types_widget.get_filter_info() - ) + product_types = self._product_types_filter_combo.get_value() + self._products_widget.set_product_type_filter(product_types) def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() From b586bf0ad604e02a424b1470bd5edea81944e7b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 14:38:32 +0100 Subject: [PATCH 18/49] renamed 'product_types_widget' to 'product_types_combo' --- .../ui/{product_types_widget.py => product_types_combo.py} | 0 client/ayon_core/tools/loader/ui/window.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename client/ayon_core/tools/loader/ui/{product_types_widget.py => product_types_combo.py} (100%) diff --git a/client/ayon_core/tools/loader/ui/product_types_widget.py b/client/ayon_core/tools/loader/ui/product_types_combo.py similarity index 100% rename from client/ayon_core/tools/loader/ui/product_types_widget.py rename to client/ayon_core/tools/loader/ui/product_types_combo.py diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 31d88d4ed7..31de0cf4e5 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -15,7 +15,7 @@ from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .products_widget import ProductsWidget -from .product_types_widget import ProductTypesCombobox +from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget From 38c4a5d4e3fcb36b97df430b78f43a12cba1cacf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:13:20 +0100 Subject: [PATCH 19/49] added controller functions --- client/ayon_core/tools/loader/abstract.py | 26 +++++++++++++++++-- client/ayon_core/tools/loader/control.py | 14 ++++++++++ .../tools/loader/models/selection.py | 18 +++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 0b790dfbbd..82703d028f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -717,8 +717,30 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[str]: Selected folder ids. - """ + """ + pass + + @abstractmethod + def get_selected_task_ids(self): + """Get selected task ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + + """ + pass + + @abstractmethod + def set_selected_tasks(self, task_ids): + """Set selected tasks. + + Args: + task_ids (Iterable[str]): Selected task ids. + + """ pass @abstractmethod @@ -729,8 +751,8 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[str]: Selected version ids. - """ + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 16cf7c31c7..8f8e7c2b15 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -198,6 +198,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def get_folder_items(self, project_name, sender=None): return self._hierarchy_model.get_folder_items(project_name, sender) + def get_task_items(self, project_name, folder_ids, sender=None): + output = [] + for folder_id in folder_ids: + output.extend(self._hierarchy_model.get_task_items( + project_name, folder_id, sender + )) + return output + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) @@ -299,6 +307,12 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def set_selected_folders(self, folder_ids): self._selection_model.set_selected_folders(folder_ids) + def get_selected_task_ids(self): + return self._selection_model.get_selected_task_ids() + + def set_selected_tasks(self, task_ids): + self._selection_model.set_selected_tasks(task_ids) + def get_selected_version_ids(self): return self._selection_model.get_selected_version_ids() diff --git a/client/ayon_core/tools/loader/models/selection.py b/client/ayon_core/tools/loader/models/selection.py index 326ff835f6..04add26f86 100644 --- a/client/ayon_core/tools/loader/models/selection.py +++ b/client/ayon_core/tools/loader/models/selection.py @@ -14,6 +14,7 @@ class SelectionModel(object): self._project_name = None self._folder_ids = set() + self._task_ids = set() self._version_ids = set() self._representation_ids = set() @@ -48,6 +49,23 @@ class SelectionModel(object): self.event_source ) + def get_selected_task_ids(self): + return self._task_ids + + def set_selected_tasks(self, task_ids): + if task_ids == self._task_ids: + return + + self._task_ids = task_ids + self._controller.emit_event( + "selection.tasks.changed", + { + "project_name": self._project_name, + "task_ids": task_ids, + }, + self.event_source + ) + def get_selected_version_ids(self): return self._version_ids From 61314835099be9afaa789a2b119096045b1379aa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:13:53 +0100 Subject: [PATCH 20/49] added task id to VersionItem --- client/ayon_core/tools/loader/abstract.py | 5 +++++ client/ayon_core/tools/loader/models/products.py | 1 + 2 files changed, 6 insertions(+) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 82703d028f..a9578032a2 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -108,6 +108,7 @@ class VersionItem: version (int): Version. Can be negative when is hero version. is_hero (bool): Is hero version. product_id (str): Product id. + task_id (Union[str, None]): Task id. thumbnail_id (Union[str, None]): Thumbnail id. published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. @@ -127,6 +128,7 @@ class VersionItem: version, is_hero, product_id, + task_id, thumbnail_id, published_time, author, @@ -140,6 +142,7 @@ class VersionItem: ): self.version_id = version_id self.product_id = product_id + self.task_id = task_id self.thumbnail_id = thumbnail_id self.version = version self.is_hero = is_hero @@ -161,6 +164,7 @@ class VersionItem: and self.version == other.version and self.version_id == other.version_id and self.product_id == other.product_id + and self.task_id == other.task_id ) def __ne__(self, other): @@ -198,6 +202,7 @@ class VersionItem: return { "version_id": self.version_id, "product_id": self.product_id, + "task_id": self.task_id, "thumbnail_id": self.thumbnail_id, "version": self.version, "is_hero": self.is_hero, diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 58eab0cabe..34acc0550c 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -55,6 +55,7 @@ def version_item_from_entity(version): version=version_num, is_hero=is_hero, product_id=version["productId"], + task_id=version["taskId"], thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, From 22c86fcf692d08079b9f7437d642baa5316cd173 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:14:03 +0100 Subject: [PATCH 21/49] added 'get_task_items' to abstract methods --- client/ayon_core/tools/loader/abstract.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index a9578032a2..bdd8f057a1 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -541,6 +541,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_task_items(self, project_name, folder_ids, sender=None): + """Task items for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[TaskItem]: List of task items. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. From 9cd7fe6253552e4a7f5045baef505449684680f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:14:17 +0100 Subject: [PATCH 22/49] products widget can filter by task ids --- .../tools/loader/ui/products_model.py | 56 ++++++++++--------- .../tools/loader/ui/products_widget.py | 19 +++++++ 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 3571788134..cebae9bca7 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -12,34 +12,35 @@ GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 -PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 -PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 +TASK_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 class ProductsModel(QtGui.QStandardItemModel): @@ -368,6 +369,7 @@ class ProductsModel(QtGui.QStandardItemModel): """ model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.task_id, TASK_ID_ROLE) model_item.setData(version_item.version, VERSION_NAME_ROLE) model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) model_item.setData( diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 41a49b8ae1..c9a2335538 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -17,6 +17,7 @@ from .products_model import ( GROUP_TYPE_ROLE, MERGED_COLOR_ROLE, FOLDER_ID_ROLE, + TASK_ID_ROLE, PRODUCT_ID_ROLE, VERSION_ID_ROLE, VERSION_STATUS_NAME_ROLE, @@ -40,6 +41,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._task_ids_filter = None self._ascending_sort = True def get_statuses_filter(self): @@ -47,6 +49,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return None return set(self._statuses_filter) + def set_tasks_filters(self, task_ids_filter): + if self._task_ids_filter == task_ids_filter: + return + self._task_ids_filter = task_ids_filter + self.invalidateFilter() + def set_product_type_filters(self, product_type_filters): if self._product_type_filters == product_type_filters: return @@ -62,6 +70,8 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) + if not self._accept_task_ids_filter(index): + return False if not self._accept_row_by_role_value( index, self._product_type_filters, PRODUCT_TYPE_ROLE @@ -75,6 +85,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return super().filterAcceptsRow(source_row, source_parent) + def _accept_task_ids_filter(self, index): + if not self._task_ids_filter: + return True + task_id = index.data(TASK_ID_ROLE) + return task_id in self._task_ids_filter + def _accept_row_by_role_value( self, index: QtCore.QModelIndex, @@ -254,6 +270,9 @@ class ProductsWidget(QtWidgets.QWidget): """ self._products_proxy_model.setFilterFixedString(name) + def set_tasks_filters(self, task_ids): + self._products_proxy_model.set_tasks_filters(task_ids) + def set_statuses_filter(self, status_names): """Set filter of version statuses. From c6b2ab3f22ef42daad9a0f8f5d9d84c14f01d581 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:15:08 +0100 Subject: [PATCH 23/49] added tasks widget for tasks filtering --- .../ayon_core/tools/loader/ui/tasks_widget.py | 346 ++++++++++++++++++ client/ayon_core/tools/loader/ui/window.py | 13 + 2 files changed, 359 insertions(+) create mode 100644 client/ayon_core/tools/loader/ui/tasks_widget.py diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py new file mode 100644 index 0000000000..3bf69d9b15 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -0,0 +1,346 @@ +import collections +import hashlib + +from qtpy import QtWidgets, QtCore, QtGui + +from ayon_core.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, + TasksQtModel, + TASKS_MODEL_SENDER_NAME, +) +from ayon_core.tools.utils.tasks_widget import ( + ITEM_ID_ROLE, + ITEM_NAME_ROLE, + PARENT_ID_ROLE, + TASK_TYPE_ROLE, +) +from ayon_core.tools.utils.lib import RefreshThread + + +class LoaderTasksQtModel(TasksQtModel): + column_labels = [ + "Task name", + "Task type", + ] + + def __init__(self, controller): + super().__init__(controller) + + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + + self._items_by_id = {} + self._groups_by_name = {} + self._last_folder_ids = set() + + def refresh(self): + """Refresh tasks for selected folders.""" + + self._refresh(self._last_project_name, self._last_folder_ids) + + def set_context(self, project_name, folder_ids): + self._refresh(project_name, folder_ids) + + # Mark some functions from 'TasksQtModel' as not implemented + def get_index_by_name(self, task_name): + raise NotImplementedError( + "Method 'get_index_by_name' is not implemented." + ) + + def get_last_folder_id(self): + raise NotImplementedError( + "Method 'get_last_folder_id' is not implemented." + ) + + + def _refresh(self, project_name, folder_ids): + self._is_refreshing = True + self._last_project_name = project_name + self._last_folder_ids = folder_ids + if not folder_ids: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread_id = hashlib.sha256( + "|".join(sorted(folder_ids)).encode() + ).hexdigest() + thread = self._refresh_threads.get(thread_id) + if thread is not None: + self._current_refresh_thread = thread + return + thread = RefreshThread( + thread_id, + self._thread_getter, + project_name, + folder_ids + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _thread_getter(self, project_name, folder_ids): + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=TASKS_MODEL_SENDER_NAME + ) + task_type_items = {} + if hasattr(self._controller, "get_task_type_items"): + task_type_items = self._controller.get_task_type_items( + project_name, sender=TASKS_MODEL_SENDER_NAME + ) + return task_items, task_type_items + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() + self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + + def _clear_items(self): + self._items_by_id = {} + self._groups_by_name = {} + super()._clear_items() + + def _fill_data_from_thread(self, thread): + task_items, task_type_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + task_type_item_by_name = { + task_type_item.name: task_type_item + for task_type_item in task_type_items + } + task_type_icon_cache = {} + current_ids = set() + items_by_name = collections.defaultdict(list) + for task_item in task_items: + task_id = task_item.task_id + current_ids.add(task_id) + item = self._items_by_id.get(task_id) + if item is None: + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setEditable(False) + self._items_by_id[task_id] = item + + icon = self._get_task_item_icon( + task_item, + task_type_item_by_name, + task_type_icon_cache + ) + name = task_item.name + item.setData(name, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.task_type, TASK_TYPE_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + items_by_name[name].append(item) + + root_item = self.invisibleRootItem() + + for task_id in set(self._items_by_id) - current_ids: + item = self._items_by_id.pop(task_id) + parent = item.parent() + if parent is None: + parent = root_item + parent.removeRow(item.row()) + + used_group_names = set() + new_root_items = [] + for name, items in items_by_name.items(): + # Make sure item is not parented + # - this is laziness to avoid re-parenting items which does + # complicate the code with no benefit + for item in items: + parent = item.parent() + # If item is in root then model is not None, and + # if parent is set then model is None + if parent is None and item.model() is None: + continue + + if parent is None: + # We can skip when task stays un-grouped + if len(items) == 1: + continue + parent = root_item + + parent.takeRow(item.row()) + + if len(items) == 1: + new_root_items.extend(items) + continue + + used_group_names.add(name) + group_item = self._groups_by_name.get(name) + if group_item is None: + group_item = QtGui.QStandardItem() + group_item.setData(name, QtCore.Qt.DisplayRole) + group_item.setEditable(False) + group_item.setColumnCount(self.columnCount()) + self._groups_by_name[name] = group_item + new_root_items.append(group_item) + + # Use icon from first item + first_item_icon = items[0].data(QtCore.Qt.DecorationRole) + task_ids = [ + item.data(ITEM_ID_ROLE) + for item in items + ] + + group_item.setData(first_item_icon, QtCore.Qt.DecorationRole) + group_item.setData("|".join(task_ids), ITEM_ID_ROLE) + + group_item.appendRows(items) + + for name in set(self._groups_by_name.keys()) - used_group_names: + group_item = self._groups_by_name.pop(name) + root_item.removeRow(group_item.row()) + + if new_root_items: + root_item.appendRows(new_root_items) + + def data(self, index, role=None): + if not index.isValid(): + return None + + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + index = self.index(index.row(), 0, index.parent()) + + if col == 1: + if role == QtCore.Qt.DisplayRole: + role = TASK_TYPE_ROLE + else: + return None + + return super().data(index, role) + + +class LoaderTasksWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() + + def __init__(self, controller, parent): + super().__init__(parent) + + tasks_view = DeselectableTreeView(self) + # tasks_view.setHeaderHidden(True) + tasks_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + tasks_view_header = tasks_view.header() + tasks_view_header.setStretchLastSection(False) + + tasks_model = LoaderTasksQtModel(controller) + tasks_proxy_model = RecursiveSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + tasks_view.setModel(tasks_proxy_model) + tasks_view_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + def set_name_filter(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + + """ + self._tasks_proxy_model.setFilterFixedString(name) + if name: + self._tasks_view.expandAll() + + def refresh(self): + self._tasks_model.refresh() + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + if event["sender"] != TASKS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_folders_selection_changed(self, event): + project_name = event["project_name"] + folder_ids = event["folder_ids"] + self._tasks_model.set_context(project_name, folder_ids) + + def _on_model_refresh(self): + self._tasks_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + item_ids = set() + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is None: + continue + item_ids |= set(item_id.split("|")) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_tasks(item_ids) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 31de0cf4e5..b54e8aab42 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -14,6 +14,7 @@ from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget +from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog @@ -170,7 +171,10 @@ class LoaderWindow(QtWidgets.QWidget): context_layout.addWidget(folders_filter_input, 0) context_layout.addWidget(folders_widget, 1) + tasks_widget = LoaderTasksWidget(controller, context_widget) + context_splitter.addWidget(context_widget) + context_splitter.addWidget(tasks_widget) context_splitter.setStretchFactor(0, 65) context_splitter.setStretchFactor(1, 35) @@ -282,6 +286,10 @@ class LoaderWindow(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) + controller.register_event_callback( + "selection.tasks.changed", + self._on_tasks_selection_change, + ) controller.register_event_callback( "selection.versions.changed", self._on_versions_selection_changed, @@ -306,6 +314,8 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_filter_input = folders_filter_input self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + self._products_filter_input = products_filter_input self._product_types_filter_combo = product_types_filter_combo self._product_status_filter_combo = product_status_filter_combo @@ -428,6 +438,9 @@ class LoaderWindow(QtWidgets.QWidget): def _on_product_filter_change(self, text): self._products_widget.set_name_filter(text) + def _on_tasks_selection_change(self, event): + self._products_widget.set_tasks_filters(event["task_ids"]) + def _on_status_filter_change(self): status_names = self._product_status_filter_combo.get_value() self._products_widget.set_statuses_filter(status_names) From de504e8c8555031dad766dbb25521ee814bcabec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:43:57 +0100 Subject: [PATCH 24/49] enhanced 'DeselectableTreeView' --- client/ayon_core/tools/utils/views.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d8ae94bf0c..d69be9b6a9 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -7,7 +7,6 @@ class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" def mousePressEvent(self, event): - index = self.indexAt(event.pos()) if not index.isValid(): # clear the selection @@ -15,7 +14,14 @@ class DeselectableTreeView(QtWidgets.QTreeView): # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - QtWidgets.QTreeView.mousePressEvent(self, event) + elif ( + self.selectionModel().isSelected(index) + and len(self.selectionModel().selectedRows()) == 1 + and event.modifiers() == QtCore.Qt.NoModifier + ): + event.setModifiers(QtCore.Qt.ControlModifier) + + super().mousePressEvent(event) class TreeView(QtWidgets.QTreeView): From 1c4cf7f6373ddbd980975f27d3dc91b3d6474c7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:44:08 +0100 Subject: [PATCH 25/49] better formatting --- client/ayon_core/tools/loader/ui/tasks_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 3bf69d9b15..e4fb7d1199 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -266,7 +266,8 @@ class LoaderTasksWidget(QtWidgets.QWidget): tasks_view = DeselectableTreeView(self) # tasks_view.setHeaderHidden(True) tasks_view.setSelectionMode( - QtWidgets.QAbstractItemView.ExtendedSelection) + QtWidgets.QAbstractItemView.ExtendedSelection + ) tasks_view_header = tasks_view.header() tasks_view_header.setStretchLastSection(False) From 8f6799002e4854a385cd7b1fdb21a9f0a0fb73a0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:59:14 +0100 Subject: [PATCH 26/49] task filtering also happens per version item --- .../tools/loader/ui/products_delegates.py | 43 ++++++++++++++++--- .../tools/loader/ui/products_widget.py | 13 ++++-- client/ayon_core/tools/loader/ui/window.py | 2 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index fba9b5b3ca..8cece4687f 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -19,6 +19,7 @@ from .products_model import ( ) STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 +TASK_ID_ROLE = QtCore.Qt.UserRole + 2 class VersionsModel(QtGui.QStandardItemModel): @@ -48,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item item.setData(version_item.status, STATUS_NAME_ROLE) + item.setData(version_item.task_id, TASK_ID_ROLE) if item.row() != idx: root_item.insertRow(idx, item) @@ -57,17 +59,30 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None + self._task_ids_filter = None def filterAcceptsRow(self, row, parent): - if self._status_filter is None: - return True + if self._status_filter is not None: + if not self._status_filter: + return False - if not self._status_filter: - return False + index = self.sourceModel().index(row, 0, parent) + status = index.data(STATUS_NAME_ROLE) + if status not in self._status_filter: + return False - index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) - return status in self._status_filter + if self._task_ids_filter: + index = self.sourceModel().index(row, 0, parent) + task_id = index.data(TASK_ID_ROLE) + if task_id not in self._task_ids_filter: + return False + return True + + def set_tasks_filter(self, task_ids): + if self._task_ids_filter == task_ids: + return + self._task_ids_filter = task_ids + self.invalidateFilter() def set_statuses_filter(self, status_names): if self._status_filter == status_names: @@ -101,6 +116,13 @@ class VersionComboBox(QtWidgets.QComboBox): def get_product_id(self): return self._product_id + def set_tasks_filter(self, task_ids): + self._proxy_model.set_tasks_filter(task_ids) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -149,6 +171,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): super().__init__(*args, **kwargs) self._editor_by_id: Dict[str, VersionComboBox] = {} + self._task_ids_filter = None self._statuses_filter = None def displayText(self, value, locale): @@ -156,6 +179,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): return "N/A" return format_version(value) + def set_tasks_filter(self, task_ids): + self._task_ids_filter = set(task_ids) + for widget in self._editor_by_id.values(): + widget.set_tasks_filter(task_ids) + def set_statuses_filter(self, status_names): self._statuses_filter = set(status_names) for widget in self._editor_by_id.values(): @@ -239,6 +267,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): version_id = index.data(VERSION_ID_ROLE) editor.update_versions(versions, version_id) + editor.set_tasks_filter(self._task_ids_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index c9a2335538..94d95b9026 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -49,7 +49,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return None return set(self._statuses_filter) - def set_tasks_filters(self, task_ids_filter): + def set_tasks_filter(self, task_ids_filter): if self._task_ids_filter == task_ids_filter: return self._task_ids_filter = task_ids_filter @@ -270,8 +270,15 @@ class ProductsWidget(QtWidgets.QWidget): """ self._products_proxy_model.setFilterFixedString(name) - def set_tasks_filters(self, task_ids): - self._products_proxy_model.set_tasks_filters(task_ids) + def set_tasks_filter(self, task_ids): + """Set filter of version tasks. + + Args: + task_ids (set[str]): Task ids. + + """ + self._version_delegate.set_tasks_filter(task_ids) + self._products_proxy_model.set_tasks_filter(task_ids) def set_statuses_filter(self, status_names): """Set filter of version statuses. diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b54e8aab42..b846484c39 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -439,7 +439,7 @@ class LoaderWindow(QtWidgets.QWidget): self._products_widget.set_name_filter(text) def _on_tasks_selection_change(self, event): - self._products_widget.set_tasks_filters(event["task_ids"]) + self._products_widget.set_tasks_filter(event["task_ids"]) def _on_status_filter_change(self): status_names = self._product_status_filter_combo.get_value() From 4d7285c5b1db76da02528a47d7f70ff83c3ddeb3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 18 Feb 2025 19:10:36 +0100 Subject: [PATCH 27/49] added folder label to task view --- client/ayon_core/tools/loader/abstract.py | 14 ++++++++ client/ayon_core/tools/loader/control.py | 12 +++++++ .../ayon_core/tools/loader/ui/tasks_widget.py | 34 ++++++++++++++++--- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index bdd8f057a1..d527428196 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -556,6 +556,20 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_folder_labels(self, project_name, folder_ids): + """Get folder labels for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Optional[str]]: Folder labels by folder id. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 8f8e7c2b15..089435140e 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -206,6 +206,18 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): )) return output + def get_folder_labels(self, project_name, folder_ids): + folder_items_by_id = self._hierarchy_model.get_folder_items_by_id( + project_name, folder_ids + ) + output = {} + for folder_id, folder_item in folder_items_by_id.items(): + label = None + if folder_item is not None: + label = folder_item.label + output[folder_id] = label + return output + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index e4fb7d1199..3a48c2fbf7 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -17,11 +17,15 @@ from ayon_core.tools.utils.tasks_widget import ( ) from ayon_core.tools.utils.lib import RefreshThread +# Role that can't clash with default 'tasks_widget' roles +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 + class LoaderTasksQtModel(TasksQtModel): column_labels = [ "Task name", "Task type", + "Folder" ] def __init__(self, controller): @@ -93,7 +97,14 @@ class LoaderTasksQtModel(TasksQtModel): task_type_items = self._controller.get_task_type_items( project_name, sender=TASKS_MODEL_SENDER_NAME ) - return task_items, task_type_items + folder_ids = { + task_item.parent_id + for task_item in task_items + } + folder_labels_by_id = self._controller.get_folder_labels( + project_name, folder_ids + ) + return task_items, task_type_items, folder_labels_by_id def _on_refresh_thread(self, thread_id): """Callback when refresh thread is finished. @@ -131,7 +142,7 @@ class LoaderTasksQtModel(TasksQtModel): super()._clear_items() def _fill_data_from_thread(self, thread): - task_items, task_type_items = thread.get_result() + task_items, task_type_items, folder_labels_by_id = thread.get_result() # Task items are refreshed if task_items is None: return @@ -165,11 +176,15 @@ class LoaderTasksQtModel(TasksQtModel): task_type_icon_cache ) name = task_item.name + folder_id = task_item.parent_id + folder_label = folder_labels_by_id.get(folder_id) + item.setData(name, QtCore.Qt.DisplayRole) item.setData(name, ITEM_NAME_ROLE) item.setData(task_item.id, ITEM_ID_ROLE) item.setData(task_item.task_type, TASK_TYPE_ROLE) - item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(folder_id, PARENT_ID_ROLE) + item.setData(folder_label, FOLDER_LABEL_ROLE) item.setData(icon, QtCore.Qt.DecorationRole) items_by_name[name].append(item) @@ -254,6 +269,12 @@ class LoaderTasksQtModel(TasksQtModel): else: return None + if col == 2: + if role == QtCore.Qt.DisplayRole: + role = FOLDER_LABEL_ROLE + else: + return None + return super().data(index, role) @@ -277,7 +298,11 @@ class LoaderTasksWidget(QtWidgets.QWidget): tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) tasks_view.setModel(tasks_proxy_model) - tasks_view_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + # Hide folder column by default + tasks_view.setColumnHidden(2, True) + tasks_view_header.setSectionResizeMode( + 0, QtWidgets.QHeaderView.Stretch + ) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -326,6 +351,7 @@ class LoaderTasksWidget(QtWidgets.QWidget): def _on_folders_selection_changed(self, event): project_name = event["project_name"] folder_ids = event["folder_ids"] + self._tasks_view.setColumnHidden(2, len(folder_ids) == 1) self._tasks_model.set_context(project_name, folder_ids) def _on_model_refresh(self): From 4b44a1bc1c8084f691a5abd6c7612ce6548ab215 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 19 Feb 2025 10:51:03 +0100 Subject: [PATCH 28/49] remove unused import --- client/ayon_core/tools/loader/ui/product_types_combo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py index ff2a70a7fa..fe136ff04c 100644 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ b/client/ayon_core/tools/loader/ui/product_types_combo.py @@ -1,4 +1,4 @@ -from qtpy import QtWidgets, QtGui, QtCore +from qtpy import QtGui, QtCore from ._multicombobox import ( CustomPaintMultiselectComboBox, From 6947680cc2b6f4834121df89c274ffede8531ae3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 19 Feb 2025 10:51:15 +0100 Subject: [PATCH 29/49] make sure items are only selectable in tasks widget --- client/ayon_core/tools/loader/ui/tasks_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 3a48c2fbf7..f3ad42ea15 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -58,6 +58,8 @@ class LoaderTasksQtModel(TasksQtModel): "Method 'get_last_folder_id' is not implemented." ) + def flags(self, _index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable def _refresh(self, project_name, folder_ids): self._is_refreshing = True From 57c813004928e2a3bbb2845aa5dd090086d2bc58 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Feb 2025 11:20:25 +0000 Subject: [PATCH 30/49] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index f2e82af12b..c4606e65f2 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.1+dev" +__version__ = "1.1.2" diff --git a/package.py b/package.py index b9629d6c51..990d712056 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.1.1+dev" +version = "1.1.2" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 9833902c16..01a8aa24ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.1+dev" +version = "1.1.2" description = "" authors = ["Ynput Team "] readme = "README.md" From c8a7a766180f59915cb50f015c62b613626e607f Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Feb 2025 11:21:05 +0000 Subject: [PATCH 31/49] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index c4606e65f2..7f3228e769 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.2" +__version__ = "1.1.2+dev" diff --git a/package.py b/package.py index 990d712056..89276751b0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.1.2" +version = "1.1.2+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 01a8aa24ef..98692d396f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.2" +version = "1.1.2+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From fe58f6150f33b67f779321f0c821dcac2564c90c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:20:33 +0100 Subject: [PATCH 32/49] ignore failed initialization of create plugin --- client/ayon_core/pipeline/create/context.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index c169df67df..24557234f4 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -755,11 +755,19 @@ class CreateContext: ).format(creator_class.host_name, self.host_name)) continue - creator = creator_class( - project_settings, - self, - self.headless - ) + # TODO report initialization error + try: + creator = creator_class( + project_settings, + self, + self.headless + ) + except Exception: + self.log.error( + f"Failed to initialize plugin: {creator_class}", + exc_info=True + ) + continue if not creator.enabled: disabled_creators[creator_identifier] = creator From 66285949a5beabadfa1cf9654610cfa9a2568788 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:02:00 +0100 Subject: [PATCH 33/49] take rows instead of removing them --- client/ayon_core/tools/utils/tasks_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 87a4c3db3b..41dc46c16a 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -53,7 +53,8 @@ class TasksQtModel(QtGui.QStandardItemModel): self._has_content = False self._remove_invalid_items() root_item = self.invisibleRootItem() - root_item.removeRows(0, root_item.rowCount()) + while root_item.rowCount() != 0: + root_item.takeRow(0) def refresh(self): """Refresh tasks for last project and folder.""" From ef0106346f53530285d5244ed2e8c20d4c951135 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:02:37 +0100 Subject: [PATCH 34/49] flags consider item flags --- client/ayon_core/tools/loader/ui/tasks_widget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index f3ad42ea15..f13f352f3a 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -58,8 +58,10 @@ class LoaderTasksQtModel(TasksQtModel): "Method 'get_last_folder_id' is not implemented." ) - def flags(self, _index): - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + def flags(self, index): + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super().flags(index) def _refresh(self, project_name, folder_ids): self._is_refreshing = True From c5043aec8e58c6576a5185e89ddfcd70a3d1f4b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:03:21 +0100 Subject: [PATCH 35/49] fix fill items from thread --- .../ayon_core/tools/loader/ui/tasks_widget.py | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index f13f352f3a..0b9f1f6008 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -195,34 +195,26 @@ class LoaderTasksQtModel(TasksQtModel): root_item = self.invisibleRootItem() - for task_id in set(self._items_by_id) - current_ids: - item = self._items_by_id.pop(task_id) - parent = item.parent() - if parent is None: - parent = root_item - parent.removeRow(item.row()) + # Make sure item is not parented + # - this is laziness to avoid re-parenting items which does + # complicate the code with no benefit + queue = collections.deque() + queue.append((None, root_item)) + while queue: + (parent, item) = queue.popleft() + if not item.hasChildren(): + if parent: + parent.takeRow(item.row()) + continue + + for row in range(item.rowCount()): + queue.append((item, item.child(row, 0))) + + queue.append((parent, item)) used_group_names = set() new_root_items = [] for name, items in items_by_name.items(): - # Make sure item is not parented - # - this is laziness to avoid re-parenting items which does - # complicate the code with no benefit - for item in items: - parent = item.parent() - # If item is in root then model is not None, and - # if parent is set then model is None - if parent is None and item.model() is None: - continue - - if parent is None: - # We can skip when task stays un-grouped - if len(items) == 1: - continue - parent = root_item - - parent.takeRow(item.row()) - if len(items) == 1: new_root_items.extend(items) continue @@ -235,7 +227,6 @@ class LoaderTasksQtModel(TasksQtModel): group_item.setEditable(False) group_item.setColumnCount(self.columnCount()) self._groups_by_name[name] = group_item - new_root_items.append(group_item) # Use icon from first item first_item_icon = items[0].data(QtCore.Qt.DecorationRole) @@ -249,9 +240,14 @@ class LoaderTasksQtModel(TasksQtModel): group_item.appendRows(items) - for name in set(self._groups_by_name.keys()) - used_group_names: - group_item = self._groups_by_name.pop(name) - root_item.removeRow(group_item.row()) + new_root_items.append(group_item) + + # Remove unused caches + for task_id in set(self._items_by_id) - current_ids: + self._items_by_id.pop(task_id) + + for name in set(self._groups_by_name) - used_group_names: + self._groups_by_name.pop(name) if new_root_items: root_item.appendRows(new_root_items) From ec41b30665220e995c5906931e94324bf154f860 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:04:28 +0100 Subject: [PATCH 36/49] validate host for 'IPublishHost' interface instead of 'ILoadHost' --- client/ayon_core/tools/utils/host_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 1eff746b9e..3d356555f3 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -7,7 +7,7 @@ import os import pyblish.api -from ayon_core.host import ILoadHost +from ayon_core.host import ILoadHost, IPublishHost from ayon_core.lib import Logger from ayon_core.pipeline import registered_host @@ -236,7 +236,7 @@ class HostToolsHelper: from ayon_core.tools.publisher.window import PublisherWindow host = registered_host() - ILoadHost.validate_load_methods(host) + IPublishHost.validate_publish_methods(host) publisher_window = PublisherWindow( controller=controller, From 8ad5489d68ceaec2eb528702f6d9789a3b0cc069 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:12:25 +0100 Subject: [PATCH 37/49] base tasks widget defines column labels using default methods --- client/ayon_core/tools/utils/tasks_widget.py | 22 +++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 41dc46c16a..30846e6cda 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -24,9 +24,14 @@ class TasksQtModel(QtGui.QStandardItemModel): """ _default_task_icon = None refreshed = QtCore.Signal() + column_labels = ["Tasks"] def __init__(self, controller): - super(TasksQtModel, self).__init__() + super().__init__() + + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) self._controller = controller @@ -337,19 +342,6 @@ class TasksQtModel(QtGui.QStandardItemModel): return self._has_content - def headerData(self, section, orientation, role): - # Show nice labels in the header - if ( - role == QtCore.Qt.DisplayRole - and orientation == QtCore.Qt.Horizontal - ): - if section == 0: - return "Tasks" - - return super(TasksQtModel, self).headerData( - section, orientation, role - ) - class TasksWidget(QtWidgets.QWidget): """Tasks widget. @@ -366,7 +358,7 @@ class TasksWidget(QtWidgets.QWidget): selection_changed = QtCore.Signal() def __init__(self, controller, parent, handle_expected_selection=False): - super(TasksWidget, self).__init__(parent) + super().__init__(parent) tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) From 62084ac684fb0cc1b5bb703321161ceef773edc3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:13:09 +0100 Subject: [PATCH 38/49] remove duplicated code from super class --- client/ayon_core/tools/loader/ui/tasks_widget.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 0b9f1f6008..9f5a17ac6e 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -31,10 +31,6 @@ class LoaderTasksQtModel(TasksQtModel): def __init__(self, controller): super().__init__(controller) - self.setColumnCount(len(self.column_labels)) - for idx, label in enumerate(self.column_labels): - self.setHeaderData(idx, QtCore.Qt.Horizontal, label) - self._items_by_id = {} self._groups_by_name = {} self._last_folder_ids = set() From 592fac87f7614100c8591641713ce2598040e51d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:50:51 +0100 Subject: [PATCH 39/49] tasks header widget defines width of first column --- client/ayon_core/tools/loader/ui/tasks_widget.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 9f5a17ac6e..14840e852f 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -281,12 +281,9 @@ class LoaderTasksWidget(QtWidgets.QWidget): super().__init__(parent) tasks_view = DeselectableTreeView(self) - # tasks_view.setHeaderHidden(True) tasks_view.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) - tasks_view_header = tasks_view.header() - tasks_view_header.setStretchLastSection(False) tasks_model = LoaderTasksQtModel(controller) tasks_proxy_model = RecursiveSortFilterProxyModel() @@ -296,9 +293,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): tasks_view.setModel(tasks_proxy_model) # Hide folder column by default tasks_view.setColumnHidden(2, True) - tasks_view_header.setSectionResizeMode( - 0, QtWidgets.QHeaderView.Stretch - ) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -323,6 +317,15 @@ class LoaderTasksWidget(QtWidgets.QWidget): self._tasks_model = tasks_model self._tasks_proxy_model = tasks_proxy_model + self._fisrt_show = True + + def showEvent(self, event): + super().showEvent(event) + if self._fisrt_show: + self._fisrt_show = False + header_widget = self._tasks_view.header() + header_widget.resizeSection(0, 200) + def set_name_filter(self, name): """Set filter of folder name. From acd446bcef27710e98395ca3af22a6c67f487ef8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:51:03 +0100 Subject: [PATCH 40/49] added no tasks item to task selection --- .../ayon_core/tools/loader/ui/tasks_widget.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 14840e852f..4841af75dd 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -3,6 +3,7 @@ import hashlib from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -15,10 +16,11 @@ from ayon_core.tools.utils.tasks_widget import ( PARENT_ID_ROLE, TASK_TYPE_ROLE, ) -from ayon_core.tools.utils.lib import RefreshThread +from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon # Role that can't clash with default 'tasks_widget' roles FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 +NO_TASKS_ID = "--no-task--" class LoaderTasksQtModel(TasksQtModel): @@ -34,6 +36,9 @@ class LoaderTasksQtModel(TasksQtModel): self._items_by_id = {} self._groups_by_name = {} self._last_folder_ids = set() + # This item is used to be able filter versions without any task + # - do not mismatch with '_empty_tasks_item' item from 'TasksQtModel' + self._no_tasks_item = None def refresh(self): """Refresh tasks for selected folders.""" @@ -59,6 +64,20 @@ class LoaderTasksQtModel(TasksQtModel): index = self.index(index.row(), 0, index.parent()) return super().flags(index) + def _get_no_tasks_item(self): + if self._no_tasks_item is None: + item = QtGui.QStandardItem("< Without task >") + icon = get_qt_icon({ + "type": "material-symbols", + "name": "indeterminate_check_box", + "color": get_default_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(NO_TASKS_ID, ITEM_ID_ROLE) + item.setEditable(False) + self._no_tasks_item = item + return self._no_tasks_item + def _refresh(self, project_name, folder_ids): self._is_refreshing = True self._last_project_name = project_name @@ -209,7 +228,9 @@ class LoaderTasksQtModel(TasksQtModel): queue.append((parent, item)) used_group_names = set() - new_root_items = [] + new_root_items = [ + self._get_no_tasks_item() + ] for name, items in items_by_name.items(): if len(items) == 1: new_root_items.extend(items) @@ -364,7 +385,10 @@ class LoaderTasksWidget(QtWidgets.QWidget): item_id = index.data(ITEM_ID_ROLE) if item_id is None: continue - item_ids |= set(item_id.split("|")) + if item_id == NO_TASKS_ID: + item_ids.add(None) + else: + item_ids |= set(item_id.split("|")) return item_ids def _on_selection_change(self): From 8825fee96ddb437eb71c16adaa1c880292dc7310 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 24 Feb 2025 10:17:14 +0100 Subject: [PATCH 41/49] fix task icons --- client/ayon_core/tools/loader/abstract.py | 20 ++++++++++++++++++++ client/ayon_core/tools/loader/control.py | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d527428196..26b476de1f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -556,6 +556,26 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_task_type_items(self, project_name, sender=None): + """Task type items for a project. + + This function may trigger events with topics + 'projects.task_types.refresh.started' and + 'projects.task_types.refresh.finished' which will contain 'sender' + value in data. + That may help to avoid re-refresh of items in UI elements. + + Args: + project_name (str): Project name. + sender (str): Who requested task type items. + + Returns: + list[TaskTypeItem]: Task type information. + + """ + pass + @abstractmethod def get_folder_labels(self, project_name, folder_ids): """Get folder labels for folder ids. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 089435140e..7959a63edb 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -206,6 +206,11 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): )) return output + def get_task_type_items(self, project_name, sender=None): + return self._projects_model.get_task_type_items( + project_name, sender + ) + def get_folder_labels(self, project_name, folder_ids): folder_items_by_id = self._hierarchy_model.get_folder_items_by_id( project_name, folder_ids From 9045c7422f6581b3d119d5f937fa2619306f3b98 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 24 Feb 2025 10:17:39 +0100 Subject: [PATCH 42/49] Change label without task > no task --- client/ayon_core/tools/loader/ui/tasks_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 4841af75dd..df0c5afe1f 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -66,7 +66,7 @@ class LoaderTasksQtModel(TasksQtModel): def _get_no_tasks_item(self): if self._no_tasks_item is None: - item = QtGui.QStandardItem("< Without task >") + item = QtGui.QStandardItem("No task") icon = get_qt_icon({ "type": "material-symbols", "name": "indeterminate_check_box", From 8ee87c9d26996b9e5e5dcf84df351bc14b27d9b3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 24 Feb 2025 10:17:51 +0100 Subject: [PATCH 43/49] no task is always last --- client/ayon_core/tools/loader/ui/tasks_widget.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index df0c5afe1f..5779fc2a01 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -295,6 +295,15 @@ class LoaderTasksQtModel(TasksQtModel): return super().data(index, role) +class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): + def lessThan(self, left, right): + if left.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return False + if right.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return True + return super().lessThan(left, right) + + class LoaderTasksWidget(QtWidgets.QWidget): refreshed = QtCore.Signal() @@ -307,7 +316,7 @@ class LoaderTasksWidget(QtWidgets.QWidget): ) tasks_model = LoaderTasksQtModel(controller) - tasks_proxy_model = RecursiveSortFilterProxyModel() + tasks_proxy_model = LoaderTasksProxyModel() tasks_proxy_model.setSourceModel(tasks_model) tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) From c6e5a8ec11ead0e051730ed868593cf413aac7ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 24 Feb 2025 11:25:01 +0100 Subject: [PATCH 44/49] initial state of product name filtering are propagated --- client/ayon_core/tools/loader/ui/product_types_combo.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py index fe136ff04c..91fa52b0e9 100644 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ b/client/ayon_core/tools/loader/ui/product_types_combo.py @@ -16,6 +16,8 @@ ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 class ProductTypesQtModel(BaseQtModel): + refreshed = QtCore.Signal() + def __init__(self, controller): self._reset_filters_on_refresh = True self._refreshing = False @@ -38,6 +40,7 @@ class ProductTypesQtModel(BaseQtModel): self._reset_filters_on_refresh = False self._refreshing = False + self.refreshed.emit() def reset_product_types_filter_on_refresh(self): self._reset_filters_on_refresh = True @@ -120,6 +123,9 @@ class ProductTypesCombobox(CustomPaintMultiselectComboBox): model=model, parent=parent ) + + model.refreshed.connect(self._on_model_refresh) + self.set_placeholder_text("Product types filter...") self._model = model self._last_project_name = None @@ -141,6 +147,9 @@ class ProductTypesCombobox(CustomPaintMultiselectComboBox): def reset_product_types_filter_on_refresh(self): self._model.reset_product_types_filter_on_refresh() + def _on_model_refresh(self): + self.value_changed.emit() + def _on_product_type_filter_change(self): lines = ["Product types filter"] for item in self.get_value_info(): From 95b7c61fe20ae33c78be5b90f302a8fd9529b88b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 25 Feb 2025 09:13:33 +0000 Subject: [PATCH 45/49] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 7f3228e769..8aff30c508 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.2+dev" +__version__ = "1.1.3" diff --git a/package.py b/package.py index 89276751b0..4f856c53c0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.1.2+dev" +version = "1.1.3" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 98692d396f..48c3479a70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.2+dev" +version = "1.1.3" description = "" authors = ["Ynput Team "] readme = "README.md" From eec161ed15e451245438023c069840c1f71de43c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 25 Feb 2025 09:14:21 +0000 Subject: [PATCH 46/49] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 8aff30c508..e533e08fe4 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.3" +__version__ = "1.1.3+dev" diff --git a/package.py b/package.py index 4f856c53c0..02e2f25384 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.1.3" +version = "1.1.3+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 48c3479a70..f065ca0c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.3" +version = "1.1.3+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From df95e252fa18b9e3d068357506c73023c6373a2d Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 26 Feb 2025 11:09:25 +0100 Subject: [PATCH 47/49] Add SXR support for image extension. --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index e9750864ac..1fda014bd8 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -53,7 +53,7 @@ IMAGE_EXTENSIONS = { ".kra", ".logluv", ".mng", ".miff", ".nrrd", ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr", - ".ras", ".rgbe", ".sgi", ".tga", + ".ras", ".rgbe", ".sgi", ".sxr", ".tga", ".tif", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xr", ".xt", ".xbm", ".xcf", ".xpm", ".xwd" } From d519d284a4f35b593ac38352e0b3220f7e891a78 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Thu, 27 Feb 2025 09:54:53 +0100 Subject: [PATCH 48/49] Change icon. --- .github/workflows/pr_unittests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_unittests.yaml b/.github/workflows/pr_unittests.yaml index 9dcb1f301f..811843d5e3 100644 --- a/.github/workflows/pr_unittests.yaml +++ b/.github/workflows/pr_unittests.yaml @@ -1,4 +1,4 @@ -name: 💯 Run Unit Tests +name: 🧐 Run Unit Tests on: push: From 8bf17b8e52434ffaa8dfa84c3f622af1281f8899 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 4 Mar 2025 20:15:25 +0200 Subject: [PATCH 49/49] remove usage of a deprecated tag: `thumb-nuke` --- client/ayon_core/plugins/publish/extract_thumbnail.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index bd2f7eb0ae..da429c1cd2 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -343,8 +343,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # to be published locally continue - valid = "review" in tags or "thumb-nuke" in tags - if not valid: + if "review" not in tags: continue if not repre.get("files"):