Merge pull request #1395 from ynput/enhancement/874-publisher-editorial-linked-instances-with-grouping-view

AY-7790 Create: Parenting of instances
This commit is contained in:
Jakub Trllo 2025-08-19 12:34:22 +02:00 committed by GitHub
commit e1781efbbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1510 additions and 810 deletions

View file

@ -21,12 +21,14 @@ from .exceptions import (
TemplateFillError,
)
from .structures import (
ParentFlags,
CreatedInstance,
ConvertorItem,
AttributeValues,
CreatorAttributeValues,
PublishAttributeValues,
PublishAttributes,
InstanceContextInfo,
)
from .utils import (
get_last_versions_for_instances,
@ -77,12 +79,14 @@ __all__ = (
"TaskNotSetError",
"TemplateFillError",
"ParentFlags",
"CreatedInstance",
"ConvertorItem",
"AttributeValues",
"CreatorAttributeValues",
"PublishAttributeValues",
"PublishAttributes",
"InstanceContextInfo",
"get_last_versions_for_instances",
"get_next_versions_for_instances",

View file

@ -41,7 +41,12 @@ from .exceptions import (
HostMissRequiredMethod,
)
from .changes import TrackChangesItem
from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
from .structures import (
PublishAttributes,
ConvertorItem,
InstanceContextInfo,
ParentFlags,
)
from .creator_plugins import (
Creator,
AutoCreator,
@ -80,6 +85,7 @@ INSTANCE_ADDED_TOPIC = "instances.added"
INSTANCE_REMOVED_TOPIC = "instances.removed"
VALUE_CHANGED_TOPIC = "values.changed"
INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed"
INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed"
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed"
CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed"
PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed"
@ -262,6 +268,8 @@ class CreateContext:
# - right now used only for 'mandatory' but can be extended
# in future
"requirement_change": BulkInfo(),
# Instance parent changed
"parent_change": BulkInfo(),
}
self._bulk_order = []
@ -1083,6 +1091,35 @@ class CreateContext:
INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback
)
def add_instance_parent_change_callback(
self, callback: Callable
) -> "EventCallback":
"""Register callback to listen to instance parent changes.
Instance changed parent or parent flags.
Data structure of event:
```python
{
"instances": [CreatedInstance, ...],
"create_context": CreateContext
}
```
Args:
callback (Callable): Callback function that will be called when
instance requirement changed.
Returns:
EventCallback: Created callback object which can be used to
stop listening.
"""
return self._event_hub.add_callback(
INSTANCE_PARENT_CHANGED_TOPIC, callback
)
def context_data_to_store(self) -> dict[str, Any]:
"""Data that should be stored by host function.
@ -1364,6 +1401,13 @@ class CreateContext:
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_instance_parent_change(self, sender: Optional[str] = None):
with self._bulk_context(
"parent_change", sender
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_publish_attr_defs_change(self, sender: Optional[str] = None):
with self._bulk_context("publish_attrs_change", sender) as bulk_info:
@ -1444,6 +1488,19 @@ class CreateContext:
with self.bulk_instance_requirement_change() as bulk_item:
bulk_item.append(instance_id)
def instance_parent_changed(self, instance_id: str) -> None:
"""Instance parent changed.
Triggered by `CreatedInstance`.
Args:
instance_id (Optional[str]): Instance id.
"""
if self._is_instance_events_ready(instance_id):
with self.bulk_instance_parent_change() as bulk_item:
bulk_item.append(instance_id)
# --- context change callbacks ---
def publish_attribute_value_changed(
self, plugin_name: str, value: dict[str, Any]
@ -2046,63 +2103,97 @@ class CreateContext:
sender (Optional[str]): Sender of the event.
"""
instance_ids_by_parent_id = collections.defaultdict(set)
for instance in self.instances:
instance_ids_by_parent_id[instance.parent_instance_id].add(
instance.id
)
instances_to_remove = list(instances)
ids_to_remove = {
instance.id
for instance in instances_to_remove
}
_queue = collections.deque()
_queue.extend(instances_to_remove)
# Add children with parent lifetime flag
while _queue:
instance = _queue.popleft()
ids_to_remove.add(instance.id)
children_ids = instance_ids_by_parent_id[instance.id]
for children_id in children_ids:
if children_id in ids_to_remove:
continue
instance = self._instances_by_id[children_id]
if instance.parent_flags & ParentFlags.parent_lifetime:
instances_to_remove.append(instance)
ids_to_remove.add(instance.id)
_queue.append(instance)
instances_by_identifier = collections.defaultdict(list)
for instance in instances:
for instance in instances_to_remove:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
# Just remove instances from context if creator is not available
missing_creators = set(instances_by_identifier) - set(self.creators)
instances = []
miss_creator_instances = []
for identifier in missing_creators:
instances.extend(
instance
for instance in instances_by_identifier[identifier]
)
miss_creator_instances.extend(instances_by_identifier[identifier])
self._remove_instances(instances, sender)
with self.bulk_remove_instances(sender):
self._remove_instances(miss_creator_instances, sender)
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
creator_instances = instances_by_identifier[identifier]
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
# Filter instances by current state of 'CreateContext'
# - in case instances were already removed as subroutine of
# previous create plugin.
creator_instances = [
instance
for instance in instances_by_identifier[identifier]
if instance.id in self._instances_by_id
]
if not creator_instances:
continue
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
)
if failed_info:
raise CreatorsRemoveFailed(failed_info)
@ -2305,6 +2396,8 @@ class CreateContext:
self._bulk_publish_attrs_change_finished(data, sender)
elif key == "requirement_change":
self._bulk_instance_requirement_change_finished(data, sender)
elif key == "parent_change":
self._bulk_instance_parent_change_finished(data, sender)
def _bulk_add_instances_finished(
self,
@ -2518,3 +2611,22 @@ class CreateContext:
{"instances": instances},
sender,
)
def _bulk_instance_parent_change_finished(
self,
instance_ids: list[str],
sender: Optional[str],
):
if not instance_ids:
return
instances = [
self.get_instance_by_id(instance_id)
for instance_id in set(instance_ids)
]
self._emit_event(
INSTANCE_PARENT_CHANGED_TOPIC,
{"instances": instances},
sender,
)

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
from enum import Enum
import typing
from typing import Optional, Dict, List, Any
@ -22,6 +23,23 @@ if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
class IntEnum(int, Enum):
"""An int-based Enum class that allows for int comparison."""
def __int__(self) -> int:
return self.value
class ParentFlags(IntEnum):
# Delete instance if parent is deleted
parent_lifetime = 1
# Active state is propagated from parent to children
# - the active state is propagated in collection phase
# NOTE It might be helpful to have a function that would return "real"
# active state for instances
share_active = 1 << 1
class ConvertorItem:
"""Item representing convertor plugin.
@ -507,7 +525,9 @@ class CreatedInstance:
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
self._is_mandatory = False
self._is_mandatory: bool = False
self._parent_instance_id: Optional[str] = None
self._parent_flags: int = 0
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@ -752,6 +772,39 @@ class CreatedInstance:
self["active"] = True
self._create_context.instance_requirement_changed(self.id)
@property
def parent_instance_id(self) -> Optional[str]:
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def set_parent(
self, instance_id: Optional[str], flags: int
) -> None:
"""Set parent instance id and parenting flags.
Args:
instance_id (Optional[str]): Parent instance id.
flags (int): Parenting flags.
"""
changed = False
if instance_id != self._parent_instance_id:
changed = True
self._parent_instance_id = instance_id
if flags is None:
flags = 0
if self._parent_flags != flags:
self._parent_flags = flags
changed = True
if changed:
self._create_context.instance_parent_changed(self.id)
def changes(self):
"""Calculate and return changes."""

View file

@ -2,11 +2,13 @@
"""
import os
import collections
import pyblish.api
from ayon_core.host import IPublishHost
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.create import CreateContext, ParentFlags
class CollectFromCreateContext(pyblish.api.ContextPlugin):
@ -36,18 +38,51 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
if project_name:
context.data["projectName"] = project_name
# Separate root instances and parented instances
instances_by_parent_id = collections.defaultdict(list)
root_instances = []
for created_instance in create_context.instances:
parent_id = created_instance.parent_instance_id
if parent_id is None:
root_instances.append(created_instance)
else:
instances_by_parent_id[parent_id].append(created_instance)
# Traverse instances from top to bottom
# - All instances without an existing parent are automatically
# eliminated
filtered_instances = []
_queue = collections.deque()
_queue.append((root_instances, True))
while _queue:
created_instances, parent_is_active = _queue.popleft()
for created_instance in created_instances:
is_active = created_instance["active"]
# Use a parent's active state if parent flags defines that
if (
created_instance.parent_flags & ParentFlags.share_active
and is_active
):
is_active = parent_is_active
if is_active:
filtered_instances.append(created_instance)
children = instances_by_parent_id[created_instance.id]
if children:
_queue.append((children, is_active))
for created_instance in filtered_instances:
instance_data = created_instance.data_to_store()
if instance_data["active"]:
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
# Update global data to context
context.data.update(create_context.context_data_to_store())

View file

@ -97,6 +97,7 @@
},
"publisher": {
"error": "#AA5050",
"disabled": "#5b6779",
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",

View file

@ -1153,6 +1153,10 @@ PixmapButton:disabled {
color: {color:publisher:error};
}
#ListViewProductName[state="disabled"] {
color: {color:publisher:disabled};
}
#PublishInfoFrame {
background: {color:bg};
border-radius: 0.3em;

View file

@ -219,6 +219,8 @@ class InstanceItem:
is_active: bool,
is_mandatory: bool,
has_promised_context: bool,
parent_instance_id: Optional[str],
parent_flags: int,
):
self._instance_id: str = instance_id
self._creator_identifier: str = creator_identifier
@ -232,6 +234,8 @@ class InstanceItem:
self._is_active: bool = is_active
self._is_mandatory: bool = is_mandatory
self._has_promised_context: bool = has_promised_context
self._parent_instance_id: Optional[str] = parent_instance_id
self._parent_flags: int = parent_flags
@property
def id(self):
@ -261,6 +265,14 @@ class InstanceItem:
def has_promised_context(self):
return self._has_promised_context
@property
def parent_instance_id(self):
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def get_variant(self):
return self._variant
@ -312,6 +324,8 @@ class InstanceItem:
instance["active"],
instance.is_mandatory,
instance.has_promised_context,
instance.parent_instance_id,
instance.parent_flags,
)
@ -486,6 +500,9 @@ class CreateModel:
self._create_context.add_instance_requirement_change_callback(
self._cc_instance_requirement_changed
)
self._create_context.add_instance_parent_change_callback(
self._cc_instance_parent_changed
)
self._create_context.reset_finalization()
@ -566,15 +583,21 @@ class CreateModel:
def set_instances_active_state(
self, active_state_by_id: Dict[str, bool]
):
changed_ids = set()
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id, active in active_state_by_id.items():
instance = self._create_context.get_instance_by_id(instance_id)
instance["active"] = active
if instance["active"] is not active:
instance["active"] = active
changed_ids.add(instance_id)
if not changed_ids:
return
self._emit_event(
"create.model.instances.context.changed",
{
"instance_ids": set(active_state_by_id.keys())
"instance_ids": changed_ids
}
)
@ -1191,6 +1214,16 @@ class CreateModel:
{"instance_ids": instance_ids},
)
def _cc_instance_parent_changed(self, event):
instance_ids = {
instance.id
for instance in event.data["instances"]
}
self._emit_event(
"create.model.instance.parent.changed",
{"instance_ids": instance_ids},
)
def _get_allowed_creators_pattern(self) -> Union[Pattern, None]:
"""Provide regex pattern for configured creator labels in this context

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Generator
from qtpy import QtWidgets, QtCore
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget
from .card_view_widgets import InstanceCardView
from .list_view_widgets import InstanceListView
from .widgets import (
AbstractInstanceView,
CreateInstanceBtn,
RemoveInstanceBtn,
ChangeViewBtn,
@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame):
product_view_cards = InstanceCardView(controller, product_views_widget)
product_list_view = InstanceListView(controller, product_views_widget)
product_list_view.set_parent_grouping(False)
product_list_view_grouped = InstanceListView(
controller, product_views_widget
)
product_list_view_grouped.set_parent_grouping(True)
product_views_layout = QtWidgets.QStackedLayout()
product_views_layout.addWidget(product_view_cards)
product_views_layout.addWidget(product_list_view)
product_views_layout.addWidget(product_list_view_grouped)
product_views_layout.setCurrentWidget(product_view_cards)
# Buttons at the bottom of product view
@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.double_clicked.connect(
self.publish_tab_requested
)
product_list_view_grouped.selection_changed.connect(
self._on_product_change
)
product_list_view_grouped.double_clicked.connect(
self.publish_tab_requested
)
product_view_cards.selection_changed.connect(
self._on_product_change
)
@ -159,16 +176,22 @@ class OverviewWidget(QtWidgets.QFrame):
"create.model.instance.requirement.changed",
self._on_instance_requirement_changed
)
controller.register_event_callback(
"create.model.instance.parent.changed",
self._on_instance_parent_changed
)
self._product_content_widget = product_content_widget
self._product_content_layout = product_content_layout
self._product_view_cards = product_view_cards
self._product_list_view = product_list_view
self._product_list_view_grouped = product_list_view_grouped
self._product_views_layout = product_views_layout
self._create_btn = create_btn
self._delete_btn = delete_btn
self._change_view_btn = change_view_btn
self._product_attributes_widget = product_attributes_widget
self._create_widget = create_widget
@ -246,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame):
)
def has_items(self):
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.has_items()
def _on_create_clicked(self):
@ -361,17 +384,18 @@ class OverviewWidget(QtWidgets.QFrame):
def _on_instance_requirement_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
def _refresh_instance_states(self, instance_ids):
current_idx = self._product_views_layout.currentIndex()
for idx in range(self._product_views_layout.count()):
if idx == current_idx:
continue
widget = self._product_views_layout.widget(idx)
if widget.refreshed:
widget.set_refreshed(False)
def _on_instance_parent_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
current_widget = self._product_views_layout.widget(current_idx)
current_widget.refresh_instance_states(instance_ids)
def _refresh_instance_states(self, instance_ids):
current_view = self._get_current_view()
for view in self._iter_views():
if view is current_view:
current_view = view
elif view.refreshed:
view.set_refreshed(False)
current_view.refresh_instance_states(instance_ids)
def _on_convert_requested(self):
self.convert_requested.emit()
@ -385,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame):
convertor plugins.
"""
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.get_selected_items()
def get_selected_legacy_convertors(self):
@ -400,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame):
return convertor_identifiers
def _change_view_type(self):
old_view = self._get_current_view()
idx = self._product_views_layout.currentIndex()
new_idx = (idx + 1) % self._product_views_layout.count()
old_view = self._product_views_layout.currentWidget()
new_view = self._product_views_layout.widget(new_idx)
new_view = self._get_view_by_idx(new_idx)
if not new_view.refreshed:
new_view.refresh()
new_view.set_refreshed(True)
@ -418,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame):
new_view.set_selected_items(
instance_ids, context_selected, convertor_identifiers
)
view_type = "list"
if new_view is self._product_list_view_grouped:
view_type = "card"
elif new_view is self._product_list_view:
view_type = "list-parent-grouping"
self._change_view_btn.set_view_type(view_type)
self._product_views_layout.setCurrentIndex(new_idx)
self._on_product_change()
def _iter_views(self) -> Generator[AbstractInstanceView, None, None]:
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
if not isinstance(widget, AbstractInstanceView):
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
yield widget
def _get_current_view(self) -> AbstractInstanceView:
widget = self._product_views_layout.currentWidget()
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _get_view_by_idx(self, idx: int) -> AbstractInstanceView:
widget = self._product_views_layout.widget(idx)
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _refresh_instances(self):
if self._refreshing_instances:
return
self._refreshing_instances = True
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_refreshed(False)
for view in self._iter_views():
view.set_refreshed(False)
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
view.refresh()
view.set_refreshed(True)
@ -444,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame):
# Give a change to process Resize Request
QtWidgets.QApplication.processEvents()
# Trigger update geometry of
widget = self._product_views_layout.currentWidget()
widget.updateGeometry()
# Trigger update geometry
view.updateGeometry()
def _on_publish_start(self):
"""Publish started."""
self._create_btn.setEnabled(False)
self._product_attributes_wrap.setEnabled(False)
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(False)
for view in self._iter_views():
view.set_active_toggle_enabled(False)
def _on_controller_reset_start(self):
"""Controller reset started."""
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(True)
for view in self._iter_views():
view.set_active_toggle_enabled(True)
def _on_publish_reset(self):
"""Context in controller has been reseted."""
@ -477,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame):
self._refresh_instances()
def _on_instances_added(self):
view = self._get_current_view()
is_card_view = False
count = 0
if isinstance(view, InstanceCardView):
is_card_view = True
count = view.get_current_instance_count()
self._refresh_instances()
if is_card_view and count < 10:
new_count = view.get_current_instance_count()
if new_count > count and new_count >= 10:
self._change_view_type()
def _on_instances_removed(self):
self._refresh_instances()

View file

@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
IconButton,
PixmapLabel,
get_qt_icon,
)
from ayon_core.tools.publisher.constants import ResetKeySequence
@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn):
self.setToolTip("Remove selected instances")
class ChangeViewBtn(PublishIconBtn):
"""Create toggle view button."""
class ChangeViewBtn(IconButton):
"""Toggle views button."""
def __init__(self, parent=None):
icon_path = get_icon_path("change_view")
super().__init__(icon_path, parent)
self.setToolTip("Swap between views")
super().__init__(parent)
self.set_view_type("list")
def set_view_type(self, view_type):
if view_type == "list":
# icon_name = "data_table"
icon_name = "dehaze"
tooltip = "Change to list view"
elif view_type == "card":
icon_name = "view_agenda"
tooltip = "Change to card view"
else:
icon_name = "segment"
tooltip = "Change to parent grouping view"
# "format_align_right"
# "segment"
icon = get_qt_icon({
"type": "material-symbols",
"name": icon_name,
})
self.setIcon(icon)
self.setToolTip(tooltip)
class AbstractInstanceView(QtWidgets.QWidget):
@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget):
"{} Method 'set_active_toggle_enabled' is not implemented."
).format(self.__class__.__name__))
def refresh_instance_states(self, instance_ids=None):
"""Refresh instance states.
Args:
instance_ids: Optional[Iterable[str]]: Instance ids to refresh.
If not passed then all instances are refreshed.
"""
raise NotImplementedError(
f"{self.__class__.__name__} Method 'refresh_instance_states'"
" is not implemented."
)
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.