mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into enhancement/run_unittest_cicd
This commit is contained in:
commit
bcdf0af4d4
27 changed files with 3753 additions and 684 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
|
||||
if not_found_folder_paths:
|
||||
joined_folder_paths = ", ".join(
|
||||
["\"{}\"".format(path) for path in not_found_folder_paths]
|
||||
[f"\"{path}\"" for path in not_found_folder_paths]
|
||||
)
|
||||
self.log.warning(
|
||||
f"Not found folder entities with paths {joined_folder_paths}."
|
||||
)
|
||||
self.log.warning((
|
||||
"Not found folder entities with paths \"{}\"."
|
||||
).format(joined_folder_paths))
|
||||
|
||||
def fill_missing_task_entities(self, context, project_name):
|
||||
self.log.debug("Querying task entities for instances.")
|
||||
|
|
|
|||
|
|
@ -1,78 +1,120 @@
|
|||
"""Plugin for collecting OTIO frame ranges and related timing information.
|
||||
|
||||
This module contains a unified plugin that handles:
|
||||
- Basic timeline frame ranges
|
||||
- Source media frame ranges
|
||||
- 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
|
||||
|
||||
import opentimelineio as otio
|
||||
import pyblish.api
|
||||
from ayon_core.pipeline.editorial import (
|
||||
get_media_range_with_retimes,
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles,
|
||||
)
|
||||
|
||||
|
||||
class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
|
||||
"""Getting otio ranges from otio_clip
|
||||
def validate_otio_clip(instance, logger):
|
||||
"""Validate if instance has required OTIO clip data.
|
||||
|
||||
Adding timeline and source ranges to instance data"""
|
||||
Args:
|
||||
instance: The instance to validate
|
||||
logger: Logger object to use for debug messages
|
||||
|
||||
label = "Collect OTIO Frame Ranges"
|
||||
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 CollectOtioRanges(pyblish.api.InstancePlugin):
|
||||
"""Collect all OTIO-related frame ranges and timing information.
|
||||
|
||||
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
|
||||
clipIn (int): Clip in point
|
||||
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 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 (
|
||||
get_media_range_with_retimes,
|
||||
otio_range_to_frame_range,
|
||||
otio_range_with_handles
|
||||
)
|
||||
"""Process the instance to collect all 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"]
|
||||
|
||||
# Collect timeline ranges if workfile start frame is available
|
||||
if "workfileFrameStart" in instance.data:
|
||||
self._collect_timeline_ranges(instance, otio_clip)
|
||||
|
||||
# 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")
|
||||
has_available_range = False
|
||||
|
||||
# Collect source ranges if clip has available range
|
||||
if has_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"]
|
||||
workfile_source_duration = instance.data.get("shotDurationFromSource")
|
||||
|
||||
# get ranges
|
||||
# Get timeline ranges
|
||||
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,
|
||||
instance
|
||||
)
|
||||
|
||||
# get source avalable start frame
|
||||
src_starting_from = otio.opentime.to_frames(
|
||||
otio_avalable_range.start_time,
|
||||
otio_avalable_range.start_time.rate)
|
||||
# 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)
|
||||
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)
|
||||
frame_end = frame_start + otio_tl_range.duration.to_frames() - 1
|
||||
|
||||
data = {
|
||||
"frameStart": frame_start,
|
||||
|
|
@ -81,13 +123,77 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
|
|||
"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 frame ranges: {pformat(data)}")
|
||||
|
||||
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()
|
||||
|
||||
# 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": 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."""
|
||||
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)
|
||||
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ class ExtractOTIOReview(
|
|||
)
|
||||
|
||||
instance.data["representations"].append(representation)
|
||||
self.log.info("Adding representation: {}".format(representation))
|
||||
self.log.debug("Adding representation: {}".format(representation))
|
||||
|
||||
def _create_representation(self, start, duration):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from operator import attrgetter
|
||||
import dataclasses
|
||||
import os
|
||||
from typing import Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pyblish.api
|
||||
try:
|
||||
|
|
@ -14,7 +14,8 @@ from ayon_core.lib import (
|
|||
BoolDef,
|
||||
UISeparatorDef,
|
||||
UILabelDef,
|
||||
EnumDef
|
||||
EnumDef,
|
||||
filter_profiles
|
||||
)
|
||||
try:
|
||||
from ayon_core.pipeline.usdlib import (
|
||||
|
|
@ -281,6 +282,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"fx": 500,
|
||||
"lighting": 600,
|
||||
}
|
||||
# Default profiles to set certain instance attribute defaults based on
|
||||
# profiles in settings
|
||||
profiles: List[Dict[str, Any]] = []
|
||||
|
||||
@classmethod
|
||||
def apply_settings(cls, project_settings):
|
||||
|
|
@ -298,6 +302,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
if contribution_layers:
|
||||
cls.contribution_layers = contribution_layers
|
||||
|
||||
cls.profiles = plugin_settings.get("profiles", [])
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
attr_values = self.get_attr_values_from_data(instance.data)
|
||||
|
|
@ -463,6 +469,28 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
# Set default target layer based on product type
|
||||
current_context_task_type = create_context.get_current_task_type()
|
||||
profile = filter_profiles(cls.profiles, {
|
||||
"product_types": instance.data["productType"],
|
||||
"task_types": current_context_task_type
|
||||
})
|
||||
if not profile:
|
||||
profile = {}
|
||||
|
||||
# Define defaults
|
||||
default_contribution_layer = profile.get(
|
||||
"contribution_layer", None)
|
||||
default_apply_as_variant = profile.get(
|
||||
"contribution_apply_as_variant", False)
|
||||
default_target_product = profile.get(
|
||||
"contribution_target_product", "usdAsset")
|
||||
default_init_as = (
|
||||
"asset"
|
||||
if profile.get("contribution_target_product") == "usdAsset"
|
||||
else "shot")
|
||||
init_as_visible = False
|
||||
|
||||
# Attributes logic
|
||||
publish_attributes = instance["publish_attributes"].get(
|
||||
cls.__name__, {})
|
||||
|
|
@ -495,7 +523,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the contribution itself will be added to the "
|
||||
"department layer."
|
||||
),
|
||||
default="usdAsset",
|
||||
default=default_target_product,
|
||||
visible=visible),
|
||||
EnumDef("contribution_target_product_init",
|
||||
label="Initialize as",
|
||||
|
|
@ -507,8 +535,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"setting will do nothing."
|
||||
),
|
||||
items=["asset", "shot"],
|
||||
default="asset",
|
||||
visible=visible),
|
||||
default=default_init_as,
|
||||
visible=visible and init_as_visible),
|
||||
|
||||
# Asset layer, e.g. model.usd, look.usd, rig.usd
|
||||
EnumDef("contribution_layer",
|
||||
|
|
@ -520,7 +548,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the list) will contribute as a stronger opinion."
|
||||
),
|
||||
items=list(cls.contribution_layers.keys()),
|
||||
default="model",
|
||||
default=default_contribution_layer,
|
||||
visible=visible),
|
||||
BoolDef("contribution_apply_as_variant",
|
||||
label="Add as variant",
|
||||
|
|
@ -532,7 +560,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"appended to as a sublayer to the department layer "
|
||||
"instead."
|
||||
),
|
||||
default=True,
|
||||
default=default_apply_as_variant,
|
||||
visible=visible),
|
||||
TextDef("contribution_variant_set_name",
|
||||
label="Variant Set Name",
|
||||
|
|
@ -588,31 +616,6 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
|
||||
|
||||
|
||||
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
|
||||
"""
|
||||
This is solely here to expose the attribute definitions for the
|
||||
Houdini "look" family.
|
||||
"""
|
||||
# TODO: Improve how this is built for the look family
|
||||
hosts = ["houdini"]
|
||||
families = ["look"]
|
||||
label = CollectUSDLayerContributions.label + " (Look)"
|
||||
|
||||
@classmethod
|
||||
def get_attr_defs_for_instance(cls, create_context, instance):
|
||||
# Filtering of instance, if needed, can be customized
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
defs = super().get_attr_defs_for_instance(create_context, instance)
|
||||
|
||||
# Update default for department layer to look
|
||||
layer_def = next(d for d in defs if d.key == "contribution_layer")
|
||||
layer_def.default = "look"
|
||||
|
||||
return defs
|
||||
|
||||
|
||||
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
|
||||
families = ["usdLayer"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -536,6 +541,55 @@ 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_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.
|
||||
|
||||
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.
|
||||
|
|
@ -717,8 +771,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 +805,8 @@ class FrontendLoaderController(_BaseLoaderController):
|
|||
|
||||
Returns:
|
||||
list[str]: Selected version ids.
|
||||
"""
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -198,6 +198,31 @@ 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_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
|
||||
)
|
||||
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)
|
||||
|
|
@ -299,6 +324,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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
169
client/ayon_core/tools/loader/ui/product_types_combo.py
Normal file
169
client/ayon_core/tools/loader/ui/product_types_combo.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from qtpy import QtGui, QtCore
|
||||
|
||||
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(BaseQtModel):
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
self._reset_filters_on_refresh = True
|
||||
self._refreshing = False
|
||||
self._bulk_change = False
|
||||
self._items_by_name = {}
|
||||
|
||||
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 refresh(self, project_name):
|
||||
self._refreshing = True
|
||||
super().refresh(project_name)
|
||||
|
||||
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 _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
|
||||
|
||||
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
|
||||
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
|
||||
if item is None:
|
||||
filter_required = True
|
||||
item = QtGui.QStandardItem(name)
|
||||
item.setData(name, PRODUCT_TYPE_ROLE)
|
||||
item.setEditable(False)
|
||||
item.setCheckable(True)
|
||||
self._items_by_name[name] = item
|
||||
|
||||
items.append(item)
|
||||
|
||||
if filter_required:
|
||||
items_filter_required[name] = item
|
||||
|
||||
if items_filter_required:
|
||||
product_types_filter = self._controller.get_product_types_filter()
|
||||
for product_type, item in items_filter_required.items():
|
||||
matching = (
|
||||
int(product_type in product_types_filter.product_types)
|
||||
+ int(product_types_filter.is_allow_list)
|
||||
)
|
||||
item.setCheckState(
|
||||
QtCore.Qt.Checked
|
||||
if matching % 2 == 0
|
||||
else QtCore.Qt.Unchecked
|
||||
)
|
||||
|
||||
items_to_remove = []
|
||||
for name in names_to_remove:
|
||||
items_to_remove.append(
|
||||
self._items_by_name.pop(name)
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
return items, items_to_remove
|
||||
|
||||
|
||||
class ProductTypesCombobox(CustomPaintMultiselectComboBox):
|
||||
def __init__(self, controller, parent):
|
||||
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
|
||||
)
|
||||
|
||||
model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
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
|
||||
)
|
||||
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._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():
|
||||
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._last_project_name = project_name
|
||||
self._model.refresh(project_name)
|
||||
|
||||
def _on_projects_refresh(self):
|
||||
if self._last_project_name:
|
||||
self._model.refresh(self._last_project_name)
|
||||
self._on_product_type_filter_change()
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
|
||||
from ayon_core.tools.utils import get_qt_icon
|
||||
|
||||
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
|
||||
|
||||
|
||||
class ProductTypesQtModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
filter_changed = QtCore.Signal()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
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 = []
|
||||
items_filter_required = {}
|
||||
for product_type_item in product_type_items:
|
||||
name = product_type_item.name
|
||||
items_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
|
||||
if item is None:
|
||||
filter_required = True
|
||||
item = QtGui.QStandardItem(name)
|
||||
item.setData(name, PRODUCT_TYPE_ROLE)
|
||||
item.setEditable(False)
|
||||
item.setCheckable(True)
|
||||
new_items.append(item)
|
||||
self._items_by_name[name] = 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():
|
||||
matching = (
|
||||
int(product_type in product_types_filter.product_types)
|
||||
+ int(product_types_filter.is_allow_list)
|
||||
)
|
||||
state = (
|
||||
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)
|
||||
|
||||
for name in items_to_remove:
|
||||
item = self._items_by_name.pop(name)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ProductTypesView(QtWidgets.QListView):
|
||||
filter_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(ProductTypesView, self).__init__(parent)
|
||||
|
||||
self.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
def reset_product_types_filter_on_refresh(self):
|
||||
self._product_types_model.reset_product_types_filter_on_refresh()
|
||||
|
||||
def _on_project_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._product_types_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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from __future__ import annotations
|
||||
import collections
|
||||
from typing import Optional
|
||||
|
||||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
|
|
@ -15,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,
|
||||
|
|
@ -36,8 +39,9 @@ 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._task_ids_filter = None
|
||||
self._ascending_sort = True
|
||||
|
||||
def get_statuses_filter(self):
|
||||
|
|
@ -45,7 +49,15 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
|
|||
return None
|
||||
return set(self._statuses_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
|
||||
self.invalidateFilter()
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -58,29 +70,41 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
|
|||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
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_task_ids_filter(index):
|
||||
return False
|
||||
|
||||
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_task_ids_filter(self, index):
|
||||
if not self._task_ids_filter:
|
||||
return True
|
||||
if not self._statuses_filter:
|
||||
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,
|
||||
filter_value: Optional[set[str]],
|
||||
role: int
|
||||
):
|
||||
if filter_value is None:
|
||||
return True
|
||||
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 +144,7 @@ class ProductsWidget(QtWidgets.QWidget):
|
|||
90, # Product type
|
||||
130, # Folder label
|
||||
60, # Version
|
||||
100, # Status
|
||||
100, # Status
|
||||
125, # Time
|
||||
75, # Author
|
||||
75, # Frames
|
||||
|
|
@ -246,6 +270,16 @@ class ProductsWidget(QtWidgets.QWidget):
|
|||
"""
|
||||
self._products_proxy_model.setFilterFixedString(name)
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
405
client/ayon_core/tools/loader/ui/tasks_widget.py
Normal file
405
client/ayon_core/tools/loader/ui/tasks_widget.py
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import collections
|
||||
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,
|
||||
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, 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):
|
||||
column_labels = [
|
||||
"Task name",
|
||||
"Task type",
|
||||
"Folder"
|
||||
]
|
||||
|
||||
def __init__(self, controller):
|
||||
super().__init__(controller)
|
||||
|
||||
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."""
|
||||
|
||||
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 flags(self, index):
|
||||
if index.column() != 0:
|
||||
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("No 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
|
||||
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
|
||||
)
|
||||
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.
|
||||
|
||||
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, folder_labels_by_id = 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
|
||||
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(folder_id, PARENT_ID_ROLE)
|
||||
item.setData(folder_label, FOLDER_LABEL_ROLE)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
items_by_name[name].append(item)
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
|
||||
# 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 = [
|
||||
self._get_no_tasks_item()
|
||||
]
|
||||
for name, items in items_by_name.items():
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
if col == 2:
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
role = FOLDER_LABEL_ROLE
|
||||
else:
|
||||
return None
|
||||
|
||||
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()
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
tasks_view = DeselectableTreeView(self)
|
||||
tasks_view.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
|
||||
tasks_model = LoaderTasksQtModel(controller)
|
||||
tasks_proxy_model = LoaderTasksProxyModel()
|
||||
tasks_proxy_model.setSourceModel(tasks_model)
|
||||
tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
tasks_view.setModel(tasks_proxy_model)
|
||||
# Hide folder column by default
|
||||
tasks_view.setColumnHidden(2, True)
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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_view.setColumnHidden(2, len(folder_ids) == 1)
|
||||
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
|
||||
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):
|
||||
item_ids = self._get_selected_item_ids()
|
||||
self._controller.set_selected_tasks(item_ids)
|
||||
|
|
@ -14,8 +14,9 @@ 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_widget import ProductTypesView
|
||||
from .product_types_combo import ProductTypesCombobox
|
||||
from .product_group_dialog import ProductGroupDialog
|
||||
from .info_widget import InfoWidget
|
||||
from .repres_widget import RepresentationsWidget
|
||||
|
|
@ -164,16 +165,16 @@ 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)
|
||||
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(product_types_widget)
|
||||
context_splitter.addWidget(tasks_widget)
|
||||
context_splitter.setStretchFactor(0, 65)
|
||||
context_splitter.setStretchFactor(1, 35)
|
||||
|
||||
|
|
@ -185,6 +186,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 +201,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 +250,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
|
||||
)
|
||||
|
|
@ -280,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,
|
||||
|
|
@ -304,9 +314,10 @@ class LoaderWindow(QtWidgets.QWidget):
|
|||
self._folders_filter_input = folders_filter_input
|
||||
self._folders_widget = folders_widget
|
||||
|
||||
self._product_types_widget = product_types_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
|
||||
self._product_group_checkbox = product_group_checkbox
|
||||
self._products_widget = products_widget
|
||||
|
|
@ -335,7 +346,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 +354,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 +378,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
|
||||
|
|
@ -423,14 +438,16 @@ 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_filter(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)
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -53,7 +58,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."""
|
||||
|
|
@ -336,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.
|
||||
|
|
@ -365,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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'core' version."""
|
||||
__version__ = "1.1.1+dev"
|
||||
__version__ = "1.1.3+dev"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name = "core"
|
||||
title = "Core"
|
||||
version = "1.1.1+dev"
|
||||
version = "1.1.3+dev"
|
||||
|
||||
client_dir = "ayon_core"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
[tool.poetry]
|
||||
name = "ayon-core"
|
||||
version = "1.1.1+dev"
|
||||
version = "1.1.3+dev"
|
||||
description = ""
|
||||
authors = ["Ynput Team <team@ynput.io>"]
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,59 @@ class ContributionLayersModel(BaseSettingsModel):
|
|||
"layer on top.")
|
||||
|
||||
|
||||
class CollectUSDLayerContributionsProfileModel(BaseSettingsModel):
|
||||
"""Profiles to define instance attribute defaults for USD contribution."""
|
||||
_layout = "expanded"
|
||||
product_types: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Product types",
|
||||
description=(
|
||||
"The product types to match this profile to. When matched, the"
|
||||
" settings below would apply to the instance as default"
|
||||
" attributes."
|
||||
),
|
||||
section="Filter"
|
||||
)
|
||||
task_types: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Task Types",
|
||||
enum_resolver=task_types_enum,
|
||||
description=(
|
||||
"The current create context task type to filter against. This"
|
||||
" allows to filter the profile to only be valid if currently "
|
||||
" creating from within that task type."
|
||||
),
|
||||
)
|
||||
contribution_layer: str = SettingsField(
|
||||
"",
|
||||
title="Contribution Department Layer",
|
||||
description=(
|
||||
"The default contribution layer to apply the contribution to when"
|
||||
" matching this profile. The layer name should be in the"
|
||||
" 'Department Layer Orders' list to get a sensible order."
|
||||
),
|
||||
section="Instance attribute defaults",
|
||||
)
|
||||
contribution_apply_as_variant: bool = SettingsField(
|
||||
True,
|
||||
title="Apply as variant",
|
||||
description=(
|
||||
"The default 'Apply as variant' state for instances matching this"
|
||||
" profile. Usually enabled for asset contributions and disabled"
|
||||
" for shot contributions."
|
||||
),
|
||||
)
|
||||
contribution_target_product: str = SettingsField(
|
||||
"usdAsset",
|
||||
title="Target Product",
|
||||
description=(
|
||||
"The default destination product name to apply the contribution to"
|
||||
" when matching this profile."
|
||||
" Usually e.g. 'usdAsset' or 'usdShot'."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CollectUSDLayerContributionsModel(BaseSettingsModel):
|
||||
enabled: bool = SettingsField(True, title="Enabled")
|
||||
contribution_layers: list[ContributionLayersModel] = SettingsField(
|
||||
|
|
@ -77,6 +130,14 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel):
|
|||
"ordering inside the USD contribution workflow."
|
||||
)
|
||||
)
|
||||
profiles: list[CollectUSDLayerContributionsProfileModel] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Profiles",
|
||||
description=(
|
||||
"Define attribute defaults for USD Contributions on publish"
|
||||
" instances."
|
||||
)
|
||||
)
|
||||
|
||||
@validator("contribution_layers")
|
||||
def validate_unique_outputs(cls, value):
|
||||
|
|
@ -1017,6 +1078,43 @@ DEFAULT_PUBLISH_VALUES = {
|
|||
{"name": "fx", "order": 500},
|
||||
{"name": "lighting", "order": 600},
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"product_types": ["model"],
|
||||
"task_types": [],
|
||||
"contribution_layer": "model",
|
||||
"contribution_apply_as_variant": True,
|
||||
"contribution_target_product": "usdAsset"
|
||||
},
|
||||
{
|
||||
"product_types": ["look"],
|
||||
"task_types": [],
|
||||
"contribution_layer": "look",
|
||||
"contribution_apply_as_variant": True,
|
||||
"contribution_target_product": "usdAsset"
|
||||
},
|
||||
{
|
||||
"product_types": ["groom"],
|
||||
"task_types": [],
|
||||
"contribution_layer": "groom",
|
||||
"contribution_apply_as_variant": True,
|
||||
"contribution_target_product": "usdAsset"
|
||||
},
|
||||
{
|
||||
"product_types": ["rig"],
|
||||
"task_types": [],
|
||||
"contribution_layer": "rig",
|
||||
"contribution_apply_as_variant": True,
|
||||
"contribution_target_product": "usdAsset"
|
||||
},
|
||||
{
|
||||
"product_types": ["usd"],
|
||||
"task_types": [],
|
||||
"contribution_layer": "assembly",
|
||||
"contribution_apply_as_variant": False,
|
||||
"contribution_target_product": "usdShot"
|
||||
},
|
||||
]
|
||||
},
|
||||
"ValidateEditorialAssetName": {
|
||||
"enabled": True,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,128 @@
|
|||
import os
|
||||
|
||||
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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue