From d3ba10caf1a3b3b3990bf00b39772824bad64a3a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jun 2024 13:56:51 +0200 Subject: [PATCH 01/58] Merge branch 'develop' into feature/AY-979_Resolve-update-to-new-publisher --- client/ayon_resolve/README.markdown | 6 +- .../RESOLVE_API_v19.0B-build20.txt | 13 + client/ayon_resolve/api/__init__.py | 49 +- client/ayon_resolve/api/constants.py | 18 + client/ayon_resolve/api/lib.py | 364 ++++++---- client/ayon_resolve/api/menu.py | 60 +- client/ayon_resolve/api/menu_style.qss | 71 -- client/ayon_resolve/api/pipeline.py | 199 ++++-- client/ayon_resolve/api/plugin.py | 638 +++++++----------- client/ayon_resolve/api/todo-rendering.py | 2 +- client/ayon_resolve/api/workio.py | 43 +- .../ayon_resolve/hooks/pre_resolve_setup.py | 4 +- client/ayon_resolve/otio/davinci_export.py | 6 +- .../plugins/create/create_shot_clip.py | 534 ++++++++------- .../plugins/create/create_workfile.py | 72 ++ client/ayon_resolve/plugins/load/load_clip.py | 12 +- .../publish/collect_current_project.py | 32 + .../plugins/publish/extract_workfile.py | 18 +- .../plugins/publish/precollect_instances.py | 10 +- .../plugins/publish/precollect_workfile.py | 54 -- .../utility_scripts/ayon_startup.scriptlib | 4 +- .../utility_scripts/develop/OTIO_export.py | 6 +- 22 files changed, 1145 insertions(+), 1070 deletions(-) create mode 100644 client/ayon_resolve/api/constants.py delete mode 100644 client/ayon_resolve/api/menu_style.qss create mode 100644 client/ayon_resolve/plugins/create/create_workfile.py create mode 100644 client/ayon_resolve/plugins/publish/collect_current_project.py delete mode 100644 client/ayon_resolve/plugins/publish/precollect_workfile.py diff --git a/client/ayon_resolve/README.markdown b/client/ayon_resolve/README.markdown index 064e791f65..b16a654538 100644 --- a/client/ayon_resolve/README.markdown +++ b/client/ayon_resolve/README.markdown @@ -10,7 +10,7 @@ ![image](https://user-images.githubusercontent.com/40640033/102792588-ffcb1c80-43a8-11eb-9c6b-bf2114ed578e.png) with installed CMake in PATH. - make sure Resolve Fusion (Fusion Tab/menu/Fusion/Fusion Settings) is set to Python 3.6 ![image](https://user-images.githubusercontent.com/40640033/102631545-280b0f00-414e-11eb-89fc-98ac268d209d.png) -- Open OpenPype **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. +- Open Ayon **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. ## Editorial setup @@ -18,9 +18,9 @@ This is how it looks on my testing project timeline ![image](https://user-images.githubusercontent.com/40640033/102637638-96ec6600-4156-11eb-9656-6e8e3ce4baf8.png) Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg confersion to jpg sequence. -1. you need to start AYON menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__OpenPype_Menu__** +1. you need to start Ayon menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__Ayon_Menu__** 2. then select any clips in `main` track and change their color to `Chocolate` -3. in OpenPype Menu select `Create` +3. in Ayon Menu select `Create` 4. in Creator select `Create Publishable Clip [New]` (temporary name) 5. set `Rename clips` to True, Master Track to `main` and Use review track to `review` as in picture ![image](https://user-images.githubusercontent.com/40640033/102643773-0d419600-4160-11eb-919e-9c2be0aecab8.png) diff --git a/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt b/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt index a2f3fa6f73..7deb0743f6 100644 --- a/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt +++ b/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt @@ -1,4 +1,8 @@ +<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt +Updated as of 18 December 2023 +======== Last Updated: 1 April 2024 +>>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. @@ -102,8 +106,11 @@ Resolve ExportRenderPreset(presetName, exportPath) --> Bool # Export a preset to a given path (string) if presetName(string) exists. ImportBurnInPreset(presetPath) --> Bool # Import a data burn in preset from a given presetPath (string) ExportBurnInPreset(presetName, exportPath) --> Bool # Export a data burn in preset to a given path (string) if presetName (string) exists. +<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt +======== GetKeyframeMode() --> keyframeMode # Returns the currently set keyframe mode (int). Refer to section 'Keyframe Mode information' below for details. SetKeyframeMode(keyframeMode) --> Bool # Returns True when 'keyframeMode'(enum) is successfully set. Refer to section 'Keyframe Mode information' below for details. +>>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt ProjectManager ArchiveProject(projectName, @@ -363,7 +370,10 @@ Timeline # Returns True on success, False otherwise. DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. ConvertTimelineToStereo() --> Bool # Converts timeline to stereo. Returns True if successful; False otherwise. +<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt +======== GetNodeGraph() --> Graph # Returns the timeline's node graph object. +>>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt TimelineItem GetName() --> string # Returns the item name. @@ -479,6 +489,8 @@ Beside primitive data types, Resolve's Python API mainly uses list and dict data As Lua does not support list and dict data structures, the Lua API implements "list" as a table with indices, e.g. { [1] = listValue1, [2] = listValue2, ... }. Similarly the Lua API implements "dict" as a table with the dictionary key as first element, e.g. { [dictKey1] = dictValue1, [dictKey2] = dictValue2, ... }. +<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt +======== Keyframe Mode information ------------------------- This section covers additional notes for the functions Resolve.GetKeyframeMode() and Resolve.SetKeyframeMode(keyframeMode). @@ -490,6 +502,7 @@ This section covers additional notes for the functions Resolve.GetKeyframeMode() Integer values returned by Resolve.GetKeyframeMode() will correspond to the enums above. +>>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt Cloud Projects Settings -------------------------------------- This section covers additional notes for the functions "ProjectManager:CreateCloudProject," "ProjectManager:ImportCloudProject," and "ProjectManager:RestoreCloudProject" diff --git a/client/ayon_resolve/api/__init__.py b/client/ayon_resolve/api/__init__.py index 3359430ef5..50df9aea2d 100644 --- a/client/ayon_resolve/api/__init__.py +++ b/client/ayon_resolve/api/__init__.py @@ -11,15 +11,13 @@ containerise, update_container, maintained_selection, - remove_instance, - list_instances ) from .lib import ( maintain_current_timeline, - publish_clip_color, get_project_manager, - get_current_project, + get_current_resolve_project, + get_current_project, # backward compatibility get_current_timeline, get_any_timeline, get_new_timeline, @@ -30,9 +28,12 @@ get_timeline_item, get_video_track_names, get_current_timeline_items, - get_pype_timeline_item_by_name, - get_timeline_item_pype_tag, - set_timeline_item_pype_tag, + get_timeline_item_by_name, + get_pype_timeline_item_by_name, # backward compatibility + get_timeline_item_ayon_tag, + get_timeline_item_pype_tag, # backward compatibility + set_timeline_item_ayon_tag, + set_timeline_item_pype_tag, # backward compatibility imprint, set_publish_attribute, get_publish_attribute, @@ -49,8 +50,10 @@ from .plugin import ( ClipLoader, TimelineItemLoader, - Creator, - PublishClip + ResolveCreator, + Creator, # backward compatibility + PublishableClip, + PublishClip, # backward compatibility ) from .workio import ( @@ -64,13 +67,18 @@ from .testing_utils import TestGUI - +# Resolve specific singletons bmdvr = None bmdvf = None +project_manager = None +media_storage = None + __all__ = [ "bmdvr", "bmdvf", + "project_manager", + "media_storage", # pipeline "ResolveHost", @@ -78,17 +86,15 @@ "containerise", "update_container", "maintained_selection", - "remove_instance", - "list_instances", # utils "get_resolve_module", # lib "maintain_current_timeline", - "publish_clip_color", "get_project_manager", - "get_current_project", + "get_current_resolve_project", + "get_current_project", # backward compatibility "get_current_timeline", "get_any_timeline", "get_new_timeline", @@ -99,9 +105,12 @@ "get_timeline_item", "get_video_track_names", "get_current_timeline_items", - "get_pype_timeline_item_by_name", - "get_timeline_item_pype_tag", - "set_timeline_item_pype_tag", + "get_timeline_item_by_name", + "get_pype_timeline_item_by_name", # backward compatibility + "get_timeline_item_ayon_tag", + "get_timeline_item_pype_tag", # backward compatibility + "set_timeline_item_ayon_tag", + "set_timeline_item_pype_tag", # backward compatibility "imprint", "set_publish_attribute", "get_publish_attribute", @@ -118,8 +127,10 @@ # plugin "ClipLoader", "TimelineItemLoader", - "Creator", - "PublishClip", + "ResolveCreator", + "Creator", # backward compatibility + "PublishableClip", + "PublishClip", # backward compatibility # workio "open_file", diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py new file mode 100644 index 0000000000..4b809e8786 --- /dev/null +++ b/client/ayon_resolve/api/constants.py @@ -0,0 +1,18 @@ +# Ayon sequential rename variables +rename_index = 0 +rename_add = 0 + +publish_clip_color = "Pink" +ayon_marker_workflow = True + +# Ayon compound clip workflow variable +ayon_tag_name = "VFX Notes" + +# Ayon marker workflow variables +ayon_marker_name = "AyonData" +ayon_marker_duration = 1 +ayon_marker_color = "Mint" +temp_marker_frame = None + +# Ayon default timeline +ayon_timeline_name = "AyonTimeline" diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 829c72b80a..5295c3e181 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -1,8 +1,8 @@ -import sys import json import re import os import contextlib +import tempfile from opentimelineio import opentime from ayon_core.lib import Logger @@ -10,34 +10,13 @@ is_overlapping_otio_ranges, frames_to_timecode ) +from ayon_core.pipeline.tempdir import create_custom_tempdir +from . import constants from ..otio import davinci_export as otio_export log = Logger.get_logger(__name__) -self = sys.modules[__name__] -self.project_manager = None -self.media_storage = None - -# OpenPype sequential rename variables -self.rename_index = 0 -self.rename_add = 0 - -self.publish_clip_color = "Pink" -self.pype_marker_workflow = True - -# OpenPype compound clip workflow variable -self.pype_tag_name = "VFX Notes" - -# OpenPype marker workflow variables -self.pype_marker_name = "OpenPypeData" -self.pype_marker_duration = 1 -self.pype_marker_color = "Mint" -self.temp_marker_frame = None - -# OpenPype default timeline -self.pype_timeline_name = "OpenPypeTimeline" - @contextlib.contextmanager def maintain_current_timeline(to_timeline: object, @@ -59,38 +38,56 @@ def maintain_current_timeline(to_timeline: object, >>> print(get_current_timeline().GetName()) timeline1 """ - project = get_current_project() - working_timeline = from_timeline or project.GetCurrentTimeline() + resolve_project = get_current_resolve_project() + working_timeline = from_timeline or resolve_project.GetCurrentTimeline() # switch to the input timeline - project.SetCurrentTimeline(to_timeline) + resolve_project.SetCurrentTimeline(to_timeline) try: # do a work yield finally: # put the original working timeline to context - project.SetCurrentTimeline(working_timeline) + resolve_project.SetCurrentTimeline(working_timeline) def get_project_manager(): - from . import bmdvr - if not self.project_manager: - self.project_manager = bmdvr.GetProjectManager() - return self.project_manager + """Get project manager object. + + Returns: + resolve.ProjectManager + """ + from . import bmdvr, project_manager + if not project_manager: + project_manager = bmdvr.GetProjectManager() + + return project_manager def get_media_storage(): - from . import bmdvr - if not self.media_storage: - self.media_storage = bmdvr.GetMediaStorage() - return self.media_storage + """Get media storage object. + + Returns: + resolve.MediaStorage + """ + from . import bmdvr, media_storage + if not media_storage: + media_storage = bmdvr.GetMediaStorage() + return media_storage + +def get_current_resolve_project(): + """Get current resolve project object. -def get_current_project(): - """Get current project object. + Returns: + resolve.Project """ - return get_project_manager().GetCurrentProject() + project_manager = get_project_manager() + return project_manager.GetCurrentProject() + +# alias for backward compatibility +get_current_project = get_current_resolve_project def get_current_timeline(new=False): @@ -104,8 +101,8 @@ def get_current_timeline(new=False): TODO: will need to reflect future `None` object: resolve.Timeline """ - project = get_current_project() - timeline = project.GetCurrentTimeline() + resolve_project = get_current_resolve_project() + timeline = resolve_project.GetCurrentTimeline() # return current timeline if any if timeline: @@ -122,10 +119,10 @@ def get_any_timeline(): Returns: object | None: resolve.Timeline """ - project = get_current_project() - timeline_count = project.GetTimelineCount() + resolve_project = get_current_resolve_project() + timeline_count = resolve_project.GetTimelineCount() if timeline_count > 0: - return project.GetTimelineByIndex(1) + return resolve_project.GetTimelineByIndex(1) def get_new_timeline(timeline_name: str = None): @@ -137,11 +134,11 @@ def get_new_timeline(timeline_name: str = None): Returns: object: resolve.Timeline """ - project = get_current_project() - media_pool = project.GetMediaPool() + resolve_project = get_current_resolve_project() + media_pool = resolve_project.GetMediaPool() new_timeline = media_pool.CreateEmptyTimeline( - timeline_name or self.pype_timeline_name) - project.SetCurrentTimeline(new_timeline) + timeline_name or constants.ayon_timeline_name) + resolve_project.SetCurrentTimeline(new_timeline) return new_timeline @@ -165,7 +162,7 @@ def create_bin(name: str, object: resolve.Folder """ # get all variables - media_pool = get_current_project().GetMediaPool() + media_pool = get_current_resolve_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() # create hierarchy of bins in case there is slash in name @@ -193,43 +190,58 @@ def create_bin(name: str, def remove_media_pool_item(media_pool_item: object) -> bool: - media_pool = get_current_project().GetMediaPool() - return media_pool.DeleteClips([media_pool_item]) + """Remove media pool item. + Args: + media_pool_item (resolve.MediaPoolItem): resolve's object -def create_media_pool_item( - files: list, - root: object = None, -) -> object: + Returns: + bool: True if success """ - Create media pool item. + resolve_project = get_current_resolve_project() + media_pool = resolve_project.GetMediaPool() + return media_pool.DeleteClips([media_pool_item]) + + +def create_media_pool_item(fpath: str, + root: object = None) -> object: + """ Create media pool item. Args: - files (list[str]): list of absolute paths to files + fpath (str): absolute path to a file root (resolve.Folder)[optional]: root folder / bin object Returns: object: resolve.MediaPoolItem """ # get all variables - media_pool = get_current_project().GetMediaPool() + media_storage = get_media_storage() + resolve_project = get_current_resolve_project() + media_pool = resolve_project.GetMediaPool() root_bin = root or media_pool.GetRootFolder() - # make sure files list is not empty and first available file exists - filepath = next((f for f in files if os.path.isfile(f)), None) - if not filepath: - raise FileNotFoundError("No file found in input files list") - # try to search in bin if the clip does not exist - existing_mpi = get_media_pool_item(filepath, root_bin) + existing_mpi = get_media_pool_item(fpath, root_bin) if existing_mpi: return existing_mpi - # add all data in folder to media pool - media_pool_items = media_pool.ImportMedia(files) + dirname, file = os.path.split(fpath) + _name, ext = os.path.splitext(file) - return media_pool_items.pop() if media_pool_items else False + # add all data in folder to media-pool + media_pool_items = media_storage.AddItemListToMediaPool( + os.path.normpath(dirname)) + + if not media_pool_items: + return False + + # if any are added then look into them for the right extension + media_pool_item = [mpi for mpi in media_pool_items + if ext in mpi.GetClipProperty("File Path")] + + # return only first found + return media_pool_item.pop() def get_media_pool_item(filepath, root: object = None) -> object: @@ -243,7 +255,8 @@ def get_media_pool_item(filepath, root: object = None) -> object: Returns: object: resolve.MediaPoolItem """ - media_pool = get_current_project().GetMediaPool() + resolve_project = get_current_resolve_project() + media_pool = resolve_project.GetMediaPool() root = root or media_pool.GetRootFolder() fname = os.path.basename(filepath) @@ -276,9 +289,10 @@ def create_timeline_item( object: resolve.TimelineItem """ # get all variables - project = get_current_project() - media_pool = project.GetMediaPool() - clip_name = media_pool_item.GetClipProperty("File Name") + resolve_project = get_current_resolve_project() + media_pool = resolve_project.GetMediaPool() + _clip_property = media_pool_item.GetClipProperty + clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() # timing variables @@ -365,15 +379,17 @@ def get_timeline_item(media_pool_item: object, def get_video_track_names() -> list: - tracks = list() - track_type = "video" timeline = get_current_timeline() + if not timeline: + return [] + + track_type = "video" # get all tracks count filtered by track type selected_track_count = timeline.GetTrackCount(track_type) # loop all tracks and get items - track_index: int + tracks = [] for track_index in range(1, (int(selected_track_count) + 1)): track_name = timeline.GetTrackName("video", track_index) tracks.append(track_name) @@ -390,7 +406,7 @@ def get_current_timeline_items( """ track_type = track_type or "video" selecting_color = selecting_color or "Chocolate" - project = get_current_project() + resolve_project = get_current_resolve_project() # get timeline anyhow timeline = ( @@ -408,7 +424,7 @@ def get_current_timeline_items( for track_index in range(1, (int(selected_track_count) + 1)): _track_name = timeline.GetTrackName(track_type, track_index) - # filter out all unmathed track names + # filter out all unmatched track names if track_name and _track_name not in track_name: continue @@ -417,7 +433,7 @@ def get_current_timeline_items( _clips[track_index] = timeline_items _data = { - "project": project, + "project": resolve_project, "timeline": timeline, "track": { "name": _track_name, @@ -437,7 +453,7 @@ def get_current_timeline_items( return selected_clips -def get_pype_timeline_item_by_name(name: str) -> object: +def get_timeline_item_by_name(name: str) -> object: """Get timeline item by name. Args: @@ -448,7 +464,7 @@ def get_pype_timeline_item_by_name(name: str) -> object: """ for _ti_data in get_current_timeline_items(): _ti_clip = _ti_data["clip"]["item"] - tag_data = get_timeline_item_pype_tag(_ti_clip) + tag_data = get_timeline_item_ayon_tag(_ti_clip) tag_name = tag_data.get("namespace") if not tag_name: continue @@ -457,20 +473,24 @@ def get_pype_timeline_item_by_name(name: str) -> object: return None -def get_timeline_item_pype_tag(timeline_item): +# alias for backward compatibility +get_pype_timeline_item_by_name = get_timeline_item_by_name + + +def get_timeline_item_ayon_tag(timeline_item): """ - Get openpype track item tag created by creator or loader plugin. + Get ayon track item tag created by creator or loader plugin. Attributes: trackItem (resolve.TimelineItem): resolve object Returns: - dict: openpype tag data + dict: ayon tag data """ return_tag = None - if self.pype_marker_workflow: - return_tag = get_pype_marker(timeline_item) + if constants.ayon_marker_workflow: + return_tag = get_ayon_marker(timeline_item) else: media_pool_item = timeline_item.GetMediaPoolItem() @@ -480,15 +500,18 @@ def get_timeline_item_pype_tag(timeline_item): return None for key, data in _tags.items(): # return only correct tag defined by global name - if key in self.pype_tag_name: + if key in constants.ayon_tag_name: return_tag = json.loads(data) return return_tag +# alias for backward compatibility +get_timeline_item_pype_tag = get_timeline_item_ayon_tag + -def set_timeline_item_pype_tag(timeline_item, data=None): +def set_timeline_item_ayon_tag(timeline_item, data=None): """ - Set openpype track item tag to input timeline_item. + Set ayon track item tag to input timeline_item. Attributes: trackItem (resolve.TimelineItem): resolve api object @@ -496,94 +519,97 @@ def set_timeline_item_pype_tag(timeline_item, data=None): Returns: dict: json loaded data """ - data = data or dict() + data = data or {} - # get available openpype tag if any - tag_data = get_timeline_item_pype_tag(timeline_item) + # get available ayon tag if any + tag_data = get_timeline_item_ayon_tag(timeline_item) - if self.pype_marker_workflow: + if constants.ayon_marker_workflow: # delete tag as it is not updatable if tag_data: - delete_pype_marker(timeline_item) + delete_ayon_marker(timeline_item) tag_data.update(data) - set_pype_marker(timeline_item, tag_data) + set_ayon_marker(timeline_item, tag_data) else: if tag_data: media_pool_item = timeline_item.GetMediaPoolItem() # it not tag then create one tag_data.update(data) media_pool_item.SetMetadata( - self.pype_tag_name, json.dumps(tag_data)) + constants.ayon_tag_name, json.dumps(tag_data)) else: tag_data = data - # if openpype tag available then update with input data + # if ayon tag available then update with input data # add it to the input track item - timeline_item.SetMetadata(self.pype_tag_name, json.dumps(tag_data)) + timeline_item.SetMetadata( + constants.ayon_tag_name, json.dumps(tag_data)) return tag_data +# alias for backward compatibility +set_timeline_item_pype_tag = set_timeline_item_ayon_tag + + def imprint(timeline_item, data=None): """ - Adding `Avalon data` into a hiero track item tag. + Adding `Ayon data` into a timeline item track item tag. Also including publish attribute into tag. Arguments: - timeline_item (hiero.core.TrackItem): hiero track item object + timeline_item (resolve.TimelineItem): resolve's object data (dict): Any data which needs to be imprinted Examples: data = { - 'folderPath': 'sq020sh0280', - 'productType': 'render', - 'productName': 'productMain' + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' } """ data = data or {} - set_timeline_item_pype_tag(timeline_item, data) + set_timeline_item_ayon_tag(timeline_item, data) # add publish attribute set_publish_attribute(timeline_item, True) def set_publish_attribute(timeline_item, value): - """ Set Publish attribute in input Tag object + """ Set Publish attribute to marker on timeline item Attribute: - tag (hiero.core.Tag): a tag object - value (bool): True or False + timeline_item (resolve.TimelineItem): resolve's object """ - tag_data = get_timeline_item_pype_tag(timeline_item) + tag_data = get_timeline_item_ayon_tag(timeline_item) tag_data["publish"] = value # set data to the publish attribute - set_timeline_item_pype_tag(timeline_item, tag_data) + set_timeline_item_ayon_tag(timeline_item, tag_data) def get_publish_attribute(timeline_item): - """ Get Publish attribute from input Tag object + """ Get Publish attribute from marker on timeline item Attribute: - tag (hiero.core.Tag): a tag object - value (bool): True or False + timeline_item (resolve.TimelineItem): resolve's object """ - tag_data = get_timeline_item_pype_tag(timeline_item) + tag_data = get_timeline_item_ayon_tag(timeline_item) return tag_data["publish"] -def set_pype_marker(timeline_item, tag_data): +def set_ayon_marker(timeline_item, tag_data): source_start = timeline_item.GetLeftOffset() item_duration = timeline_item.GetDuration() frame = int(source_start + (item_duration / 2)) # marker attributes frameId = (frame / 10) * 10 - color = self.pype_marker_color - name = self.pype_marker_name + color = constants.ayon_marker_color + name = constants.ayon_marker_name note = json.dumps(tag_data) - duration = (self.pype_marker_duration / 10) * 10 + duration = (constants.ayon_marker_duration / 10) * 10 timeline_item.AddMarker( frameId, @@ -594,22 +620,26 @@ def set_pype_marker(timeline_item, tag_data): ) -def get_pype_marker(timeline_item): +def get_ayon_marker(timeline_item): timeline_item_markers = timeline_item.GetMarkers() - for marker_frame, marker in timeline_item_markers.items(): - color = marker["color"] - name = marker["name"] - if name == self.pype_marker_name and color == self.pype_marker_color: - note = marker["note"] - self.temp_marker_frame = marker_frame + for marker_frame in timeline_item_markers: + note = timeline_item_markers[marker_frame]["note"] + color = timeline_item_markers[marker_frame]["color"] + name = timeline_item_markers[marker_frame]["name"] + print(f"_ marker data: {marker_frame} | {name} | {color} | {note}") + if ( + name == constants.ayon_marker_name + and color == constants.ayon_marker_color + ): + constants.temp_marker_frame = marker_frame return json.loads(note) - return dict() + return {} -def delete_pype_marker(timeline_item): - timeline_item.DeleteMarkerAtFrame(self.temp_marker_frame) - self.temp_marker_frame = None +def delete_ayon_marker(timeline_item): + timeline_item.DeleteMarkerAtFrame(constants.temp_marker_frame) + constants.temp_marker_frame = None def create_compound_clip(clip_data, name, folder): @@ -626,14 +656,14 @@ def create_compound_clip(clip_data, name, folder): resolve.MediaPoolItem: media pool item with compound clip timeline(cct) """ # get basic objects form data - project = clip_data["project"] + resolve_project = clip_data["project"] timeline = clip_data["timeline"] clip = clip_data["clip"] # get details of objects clip_item = clip["item"] - mp = project.GetMediaPool() + mp = resolve_project.GetMediaPool() # get clip attributes clip_attributes = get_clip_attributes(clip_item) @@ -669,7 +699,7 @@ def create_compound_clip(clip_data, name, folder): if c.GetName() in name), None) if cct: - print(f"Compound clip exists: {cct}") + print(f"_ cct exists: {cct}") else: # Create empty timeline in current folder and give name: cct = mp.CreateEmptyTimeline(name) @@ -678,7 +708,7 @@ def create_compound_clip(clip_data, name, folder): clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) - print(f"Compound clip created: {cct}") + print(f"_ cct created: {cct}") with maintain_current_timeline(cct, tl_origin): # Add input clip to the current timeline: @@ -688,10 +718,10 @@ def create_compound_clip(clip_data, name, folder): "endFrame": mp_last_frame }]) - # Add collected metadata and attributes to the comound clip: - if mp_item.GetMetadata(self.pype_tag_name): - clip_attributes[self.pype_tag_name] = mp_item.GetMetadata( - self.pype_tag_name)[self.pype_tag_name] + # Add collected metadata and attributes to the compound clip: + if mp_item.GetMetadata(constants.ayon_tag_name): + clip_attributes[constants.ayon_tag_name] = mp_item.GetMetadata( + constants.ayon_tag_name)[constants.ayon_tag_name] # stringify clip_attributes = json.dumps(clip_attributes) @@ -701,7 +731,7 @@ def create_compound_clip(clip_data, name, folder): cct.SetMetadata(k, v) # add metadata to cct - cct.SetMetadata(self.pype_tag_name, clip_attributes) + cct.SetMetadata(constants.ayon_tag_name, clip_attributes) # reset start timecode of the compound clip cct.SetClipProperty("Start TC", _mp_props("Start TC")) @@ -775,7 +805,7 @@ def _validate_tc(x): def get_pype_clip_metadata(clip): """ - Get openpype metadata created by creator plugin + Get ayon metadata created by creator plugin Attributes: clip (resolve.TimelineItem): resolve's object @@ -786,7 +816,7 @@ def get_pype_clip_metadata(clip): mp_item = clip.GetMediaPoolItem() metadata = mp_item.GetMetadata() - return metadata.get(self.pype_tag_name) + return metadata.get(constants.ayon_tag_name) def get_clip_attributes(clip): @@ -831,21 +861,21 @@ def set_project_manager_to_folder_name(folder_name): """ # initialize project manager - get_project_manager() + project_manager = get_project_manager() set_folder = False # go back to root folder - if self.project_manager.GotoRootFolder(): + if project_manager.GotoRootFolder(): log.info(f"Testing existing folder: {folder_name}") folders = _convert_resolve_list_type( - self.project_manager.GetFoldersInCurrentFolder()) + project_manager.GetFoldersInCurrentFolder()) log.info(f"Testing existing folders: {folders}") # get me first available folder object # with the same name as in `folder_name` else return False if next((f for f in folders if f in folder_name), False): log.info(f"Found existing folder: {folder_name}") - set_folder = self.project_manager.OpenFolder(folder_name) + set_folder = project_manager.OpenFolder(folder_name) if set_folder: return True @@ -853,11 +883,11 @@ def set_project_manager_to_folder_name(folder_name): # if folder by name is not existent then create one # go back to root folder log.info(f"Folder `{folder_name}` not found and will be created") - if self.project_manager.GotoRootFolder(): + if project_manager.GotoRootFolder(): try: # create folder by given name - self.project_manager.CreateFolder(folder_name) - self.project_manager.OpenFolder(folder_name) + project_manager.CreateFolder(folder_name) + project_manager.OpenFolder(folder_name) return True except NameError as e: log.error((f"Folder with name `{folder_name}` cannot be created!" @@ -878,13 +908,13 @@ def _convert_resolve_list_type(resolve_list): def create_otio_time_range_from_timeline_item_data(timeline_item_data): timeline_item = timeline_item_data["clip"]["item"] - project = timeline_item_data["project"] + resolve_project = timeline_item_data["project"] timeline = timeline_item_data["timeline"] timeline_start = timeline.GetStartFrame() frame_start = int(timeline_item.GetStart() - timeline_start) frame_duration = int(timeline_item.GetDuration()) - fps = project.GetSetting("timelineFrameRate") + fps = resolve_project.GetSetting("timelineFrameRate") return otio_export.create_otio_time_range( frame_start, frame_duration, fps) @@ -921,13 +951,53 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): # add pypedata marker to otio_clip metadata for marker in otio_clip.markers: - if self.pype_marker_name in marker.name: + if constants.ayon_marker_name in marker.name: otio_clip.metadata.update(marker.metadata) return {"otioClip": otio_clip} return None +def get_timeline_otio_filepath(project_name, anatomy=None, timeline=None): + """Get timeline otio filepath. + + Args: + project_name (str): ayon project name + anatomy (ayon_core.pipeline.Anatomy)[optional]: Anatomy object + timeline (resolve.Timeline)[optional]: resolve's object + + Returns: + str: temporary otio filepath + """ + from . import bmdvr + resolve_project = get_current_resolve_project() + timeline = resolve_project.GetCurrentTimeline() + timeline_name = timeline.GetName() + + # get custom staging dir + custom_temp_dir = create_custom_tempdir(project_name, anatomy) + staging_dir = os.path.normpath( + tempfile.mkdtemp( + prefix="resolve_otio_tmp_", + dir=custom_temp_dir + ) + ) + filename = os.path.join(staging_dir, f"{timeline_name}.otio") + + # Native otio export is available from Resolve 18.5 + # [major, minor, patch, build, suffix] + resolve_version = bmdvr.GetVersion() + if resolve_version[0] < 18 or resolve_version[1] < 5: + # if it is lower then use ayon's otio exporter + otio_timeline = otio_export.create_otio_timeline( + resolve_project, timeline=timeline) + otio_export.write_to_file(otio_timeline, filename) + + timeline.Export(filename, bmdvr.EXPORT_OTIO) + + return filename + + def get_reformated_path(path, padded=False, first=False): """ Return fixed python expression path diff --git a/client/ayon_resolve/api/menu.py b/client/ayon_resolve/api/menu.py index fc2c15ad6d..fbe91811db 100644 --- a/client/ayon_resolve/api/menu.py +++ b/client/ayon_resolve/api/menu.py @@ -5,7 +5,8 @@ from ayon_core.tools.utils import host_tools from ayon_core.pipeline import registered_host - +from ayon_core.style import load_stylesheet +from ayon_core.resources import get_ayon_icon_filepath MENU_LABEL = os.environ["AYON_MENU_LABEL"] @@ -44,6 +45,10 @@ def __init__(self, *args, **kwargs): self.setObjectName(f"{MENU_LABEL}Menu") + icon_path = get_ayon_icon_filepath() + icon = QtGui.QIcon(icon_path) + self.setWindowIcon(icon) + self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint @@ -57,15 +62,11 @@ def __init__(self, *args, **kwargs): save_current_btn = QtWidgets.QPushButton("Save current file", self) workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) create_btn = QtWidgets.QPushButton("Create ...", self) - publish_btn = QtWidgets.QPushButton("Publish ...", self) + publish_btn = QtWidgets.QPushButton("Publish...", self) load_btn = QtWidgets.QPushButton("Load ...", self) - inventory_btn = QtWidgets.QPushButton("Manager ...", self) - subsetm_btn = QtWidgets.QPushButton("Subset Manager ...", self) - libload_btn = QtWidgets.QPushButton("Library ...", self) - experimental_btn = QtWidgets.QPushButton( - "Experimental tools ...", self - ) - # rename_btn = QtWidgets.QPushButton("Rename", self) + inventory_btn = QtWidgets.QPushButton("Manage...", self) + libload_btn = QtWidgets.QPushButton("Library...", self) + # set_colorspace_btn = QtWidgets.QPushButton( # "Set colorspace from presets", self # ) @@ -78,32 +79,20 @@ def __init__(self, *args, **kwargs): layout.addWidget(save_current_btn) - layout.addWidget(Spacer(15, self)) - + layout.addSpacing(15) layout.addWidget(workfiles_btn) + + layout.addSpacing(15) + layout.addWidget(create_btn) - layout.addWidget(publish_btn) layout.addWidget(load_btn) + layout.addWidget(publish_btn) layout.addWidget(inventory_btn) - layout.addWidget(subsetm_btn) - layout.addWidget(Spacer(15, self)) + layout.addSpacing(15) layout.addWidget(libload_btn) - # layout.addWidget(Spacer(15, self)) - - # layout.addWidget(rename_btn) - - # layout.addWidget(Spacer(15, self)) - - # layout.addWidget(set_colorspace_btn) - # layout.addWidget(reset_resolution_btn) - layout.addWidget(Spacer(15, self)) - layout.addWidget(experimental_btn) - - self.setLayout(layout) - save_current_btn.clicked.connect(self.on_save_current_clicked) save_current_btn.setShortcut(QtGui.QKeySequence.Save) workfiles_btn.clicked.connect(self.on_workfile_clicked) @@ -111,12 +100,13 @@ def __init__(self, *args, **kwargs): publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) inventory_btn.clicked.connect(self.on_inventory_clicked) - subsetm_btn.clicked.connect(self.on_subsetm_clicked) libload_btn.clicked.connect(self.on_libload_clicked) - # rename_btn.clicked.connect(self.on_rename_clicked) + # set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) # reset_resolution_btn.clicked.connect(self.on_set_resolution_clicked) - experimental_btn.clicked.connect(self.on_experimental_clicked) + + # Resize width, make height as small fitting as possible + self.resize(200, 1) def on_save_current_clicked(self): host = registered_host() @@ -136,11 +126,11 @@ def on_workfile_clicked(self): def on_create_clicked(self): print("Clicked Create") - host_tools.show_creator() + host_tools.show_publisher(tab="create") def on_publish_clicked(self): print("Clicked Publish") - host_tools.show_publish(parent=None) + host_tools.show_publisher(tab="publish") def on_load_clicked(self): print("Clicked Load") @@ -150,10 +140,6 @@ def on_inventory_clicked(self): print("Clicked Inventory") host_tools.show_scene_inventory() - def on_subsetm_clicked(self): - print("Clicked Subset Manager") - host_tools.show_subset_manager() - def on_libload_clicked(self): print("Clicked Library") host_tools.show_library_loader() @@ -167,8 +153,6 @@ def on_set_colorspace_clicked(self): def on_set_resolution_clicked(self): print("Clicked Set Resolution") - def on_experimental_clicked(self): - host_tools.show_experimental_tools_dialog() def launch_ayon_menu(): diff --git a/client/ayon_resolve/api/menu_style.qss b/client/ayon_resolve/api/menu_style.qss deleted file mode 100644 index ad8932d881..0000000000 --- a/client/ayon_resolve/api/menu_style.qss +++ /dev/null @@ -1,71 +0,0 @@ -QWidget { - background-color: #282828; - border-radius: 3; - font-size: 13px; -} - -QComboBox { - border: 1px solid #090909; - background-color: #201f1f; - color: #ffffff; -} - -QComboBox QAbstractItemView -{ - color: white; -} - -QPushButton { - border: 1px solid #090909; - background-color: #201f1f; - color: #ffffff; - padding: 5; -} - -QPushButton:focus { - background-color: "#171717"; - color: #d0d0d0; -} - -QPushButton:hover { - background-color: "#171717"; - color: #e64b3d; -} - -QSpinBox { - border: 1px solid #090909; - background-color: #201f1f; - color: #ffffff; - padding: 2; - max-width: 8em; - qproperty-alignment: AlignCenter; -} - -QLineEdit { - border: 1px solid #090909; - border-radius: 3px; - background-color: #201f1f; - color: #ffffff; - padding: 2; - min-width: 10em; - qproperty-alignment: AlignCenter; -} - -#AYONMenu { - qproperty-alignment: AlignLeft; - min-width: 10em; - border: 1px solid #fef9ef; -} - -QVBoxLayout { - background-color: #282828; -} - -#Divider { - border: 1px solid #090909; - background-color: #585858; -} - -QLabel { - color: #77776b; -} diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 05d2c9bcd1..6ae7c3468f 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -4,6 +4,9 @@ import os import json import contextlib +import atexit +import tempfile +import json from collections import OrderedDict from pyblish import api as pyblish @@ -19,7 +22,8 @@ from ayon_core.host import ( HostBase, IWorkfileHost, - ILoadHost + ILoadHost, + IPublishHost ) from . import lib @@ -45,7 +49,7 @@ AVALON_CONTAINERS = ":AVALON_CONTAINERS" -class ResolveHost(HostBase, IWorkfileHost, ILoadHost): +class ResolveHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "resolve" def install(self): @@ -97,6 +101,12 @@ def get_workfile_extensions(self): def get_containers(self): return ls() + def get_context_data(self): + return {} + + def update_context_data(self, data, changes): + pass + def containerise(timeline_item, name, @@ -104,20 +114,20 @@ def containerise(timeline_item, context, loader=None, data=None): - """Bundle Hiero's object into an assembly and imprint it with metadata + """Bundle Resolve's object into an assembly and imprint it with metadata - Containerisation enables a tracking of version, author and origin + Containerization enables a tracking of version, author and origin for loaded assets. Arguments: - timeline_item (hiero.core.TrackItem): object to imprint as container + timeline_item (resolve.TimelineItem): The object to containerise name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (str, optional): Name of node used to produce this container. Returns: - timeline_item (hiero.core.TrackItem): containerised object + timeline_item (resolve.TimelineItem): containerized object """ @@ -133,7 +143,7 @@ def containerise(timeline_item, if data: data_imprint.update(data) - lib.set_timeline_item_pype_tag(timeline_item, data_imprint) + lib.set_timeline_item_ayon_tag(timeline_item, data_imprint) return timeline_item @@ -180,10 +190,10 @@ def ls(): def parse_container(timeline_item, validate=True): - """Return container data from timeline_item's openpype tag. + """Return container data from timeline_item's marker data. Args: - timeline_item (hiero.core.TrackItem): A containerised track item. + timeline_item (resolve.TimelineItem): A containerized track item. validate (bool)[optional]: validating with avalon scheme Returns: @@ -191,7 +201,7 @@ def parse_container(timeline_item, validate=True): """ # convert tag metadata to normal keys names - data = lib.get_timeline_item_pype_tag(timeline_item) + data = lib.get_timeline_item_ayon_tag(timeline_item) if validate and data and data.get("schema"): schema.validate(data) @@ -217,19 +227,19 @@ def parse_container(timeline_item, validate=True): def update_container(timeline_item, data=None): - """Update container data to input timeline_item's openpype tag. + """Update container data to input timeline_item's ayon marker data. Args: - timeline_item (hiero.core.TrackItem): A containerised track item. - data (dict)[optional]: dictionery with data to be updated + timeline_item (resolve.TimelineItem): A containerized track item. + data (dict)[optional]: dictionary with data to be updated Returns: bool: True if container was updated correctly """ - data = data or dict() + data = data or {} - container = lib.get_timeline_item_pype_tag(timeline_item) + container = lib.get_timeline_item_ayon_tag(timeline_item) for _key, _value in container.items(): try: @@ -238,7 +248,7 @@ def update_container(timeline_item, data=None): pass log.info("Updating container: `{}`".format(timeline_item)) - return bool(lib.set_timeline_item_pype_tag(timeline_item, container)) + return bool(lib.set_timeline_item_ayon_tag(timeline_item, container)) @contextlib.contextmanager @@ -277,49 +287,136 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): set_publish_attribute(timeline_item, new_value) -def remove_instance(instance): - """Remove instance marker from track item.""" - instance_id = instance.get("uuid") +class HostContext: + _context_json_path = None + + @staticmethod + def _on_exit(): + if ( + HostContext._context_json_path + and os.path.exists(HostContext._context_json_path) + ): + os.remove(HostContext._context_json_path) + + @classmethod + def get_context_json_path(cls): + if cls._context_json_path is None: + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="resolve_", suffix=".json" + ) + output_file.close() + cls._context_json_path = output_file.name + atexit.register(HostContext._on_exit) + print(cls._context_json_path) + return cls._context_json_path + + @classmethod + def _get_data(cls, group=None): + json_path = cls.get_context_json_path() + data = {} + if not os.path.exists(json_path): + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + content = json_stream.read() + if content: + data = json.loads(content) + if group is None: + return data + return data.get(group) + + @classmethod + def _save_data(cls, group, new_data): + json_path = cls.get_context_json_path() + data = cls._get_data() + data[group] = new_data + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def get_instances(cls): + return cls._get_data("instances") or [] + + @classmethod + def save_instances(cls, instances): + cls._save_data("instances", instances) + + @classmethod + def get_context_data(cls): + return cls._get_data("context") or {} + + @classmethod + def save_context_data(cls, data): + cls._save_data("context", data) + + @classmethod + def get_project_name(cls): + return cls._get_data("project_name") + + @classmethod + def set_project_name(cls, project_name): + cls._save_data("project_name", project_name) + + @classmethod + def get_data_to_store(cls): + return { + "project_name": cls.get_project_name(), + "instances": cls.get_instances(), + "context": cls.get_context_data(), + } - selected_timeline_items = lib.get_current_timeline_items( - filter=True, selecting_color=lib.publish_clip_color) - found_ti = None - for timeline_item_data in selected_timeline_items: - timeline_item = timeline_item_data["clip"]["item"] +def list_instances(): + return HostContext.get_instances() - # get openpype tag data - tag_data = lib.get_timeline_item_pype_tag(timeline_item) - _ti_id = tag_data.get("uuid") - if _ti_id == instance_id: - found_ti = timeline_item - break - if found_ti is None: - return +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() - # removing instance by marker color - print(f"Removing instance: {found_ti.GetName()}") - found_ti.DeleteMarkersByColor(lib.pype_marker_color) + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["instance_id"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + HostContext.save_instances(instances) -def list_instances(): - """List all created instances from current workfile.""" - listed_instances = [] - selected_timeline_items = lib.get_current_timeline_items( - filter=True, selecting_color=lib.publish_clip_color) - for timeline_item_data in selected_timeline_items: - timeline_item = timeline_item_data["clip"]["item"] - ti_name = timeline_item.GetName().split(".")[0] +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["instance_id"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["instance_id"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + - # get openpype tag data - tag_data = lib.get_timeline_item_pype_tag(timeline_item) +def get_context_data(): + return HostContext.get_context_data() - if tag_data: - asset = tag_data.get("asset") - product_name = tag_data.get("productName") - tag_data["label"] = f"{ti_name} [{asset}-{product_name}]" - listed_instances.append(tag_data) - return listed_instances +def update_context_data(data, changes): + HostContext.save_context_data(data) \ No newline at end of file diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 0b339cdf7c..ba6ac9719e 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -1,290 +1,35 @@ import re import uuid -import copy import qargparse -from qtpy import QtWidgets, QtCore -from ayon_core.settings import get_current_project_settings +from ayon_core.pipeline.context_tools import get_current_project_asset + +from ayon_core.lib import BoolDef + from ayon_core.pipeline import ( - LegacyCreator, LoaderPlugin, - Anatomy + Creator as NewCreator ) -from . import lib -from .menu import load_stylesheet - - -class CreatorWidget(QtWidgets.QDialog): - - # output items - items = {} - - def __init__(self, name, info, ui_inputs, parent=None): - super(CreatorWidget, self).__init__(parent) - - self.setObjectName(name) - - self.setWindowFlags( - QtCore.Qt.Window - | QtCore.Qt.CustomizeWindowHint - | QtCore.Qt.WindowTitleHint - | QtCore.Qt.WindowCloseButtonHint - | QtCore.Qt.WindowStaysOnTopHint - ) - self.setWindowTitle(name or "OpenPype Creator Input") - self.resize(500, 700) - - # Where inputs and labels are set - self.content_widget = [QtWidgets.QWidget(self)] - top_layout = QtWidgets.QFormLayout(self.content_widget[0]) - top_layout.setObjectName("ContentLayout") - top_layout.addWidget(Spacer(5, self)) - - # first add widget tag line - top_layout.addWidget(QtWidgets.QLabel(info)) - - # main dynamic layout - self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) - self.scroll_area.setVerticalScrollBarPolicy( - QtCore.Qt.ScrollBarAsNeeded) - self.scroll_area.setVerticalScrollBarPolicy( - QtCore.Qt.ScrollBarAlwaysOn) - self.scroll_area.setHorizontalScrollBarPolicy( - QtCore.Qt.ScrollBarAlwaysOff) - self.scroll_area.setWidgetResizable(True) - - self.content_widget.append(self.scroll_area) - - scroll_widget = QtWidgets.QWidget(self) - in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) - self.content_layout = [in_scroll_area] - - # add preset data into input widget layout - self.items = self.populate_widgets(ui_inputs) - self.scroll_area.setWidget(scroll_widget) - - # Confirmation buttons - btns_widget = QtWidgets.QWidget(self) - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - - cancel_btn = QtWidgets.QPushButton("Cancel") - btns_layout.addWidget(cancel_btn) - - ok_btn = QtWidgets.QPushButton("Ok") - btns_layout.addWidget(ok_btn) - - # Main layout of the dialog - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.setSpacing(0) - - # adding content widget - for w in self.content_widget: - main_layout.addWidget(w) - - main_layout.addWidget(btns_widget) - - ok_btn.clicked.connect(self._on_ok_clicked) - cancel_btn.clicked.connect(self._on_cancel_clicked) - - stylesheet = load_stylesheet() - self.setStyleSheet(stylesheet) - - def _on_ok_clicked(self): - self.result = self.value(self.items) - self.close() - - def _on_cancel_clicked(self): - self.result = None - self.close() - - def value(self, data, new_data=None): - new_data = new_data or {} - for k, v in data.items(): - new_data[k] = { - "target": None, - "value": None - } - if v["type"] == "dict": - new_data[k]["target"] = v["target"] - new_data[k]["value"] = self.value(v["value"]) - if v["type"] == "section": - new_data.pop(k) - new_data = self.value(v["value"], new_data) - elif getattr(v["value"], "currentText", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].currentText() - elif getattr(v["value"], "isChecked", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].isChecked() - elif getattr(v["value"], "value", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].value() - elif getattr(v["value"], "text", None): - new_data[k]["target"] = v["target"] - new_data[k]["value"] = v["value"].text() - - return new_data - - def camel_case_split(self, text): - matches = re.finditer( - '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) - return " ".join([str(m.group(0)).capitalize() for m in matches]) - - def create_row(self, layout, type, text, **kwargs): - # get type attribute from qwidgets - attr = getattr(QtWidgets, type) - - # convert label text to normal capitalized text with spaces - label_text = self.camel_case_split(text) - - # assign the new text to label widget - label = QtWidgets.QLabel(label_text) - label.setObjectName("LineLabel") - - # create attribute name text strip of spaces - attr_name = text.replace(" ", "") - - # create attribute and assign default values - setattr( - self, - attr_name, - attr(parent=self)) - - # assign the created attribute to variable - item = getattr(self, attr_name) - for func, val in kwargs.items(): - if getattr(item, func): - func_attr = getattr(item, func) - if isinstance(val, tuple): - func_attr(*val) - else: - func_attr(val) - - # add to layout - layout.addRow(label, item) - - return item +from ayon_core.pipeline.create import ( + Creator, + HiddenCreator, + CreatedInstance, + cache_and_get_instances, +) - def populate_widgets(self, data, content_layout=None): - """ - Populate widget from input dict. +from .pipeline import ( + list_instances, + update_instances, + remove_instances, + HostContext, +) - Each plugin has its own set of widget rows defined in dictionary - each row values should have following keys: `type`, `target`, - `label`, `order`, `value` and optionally also `toolTip`. +from . import lib, constants - Args: - data (dict): widget rows or organized groups defined - by types `dict` or `section` - content_layout (QtWidgets.QFormLayout)[optional]: used when nesting - Returns: - dict: redefined data dict updated with created widgets - - """ - - content_layout = content_layout or self.content_layout[-1] - # fix order of process by defined order value - ordered_keys = list(data.keys()) - for k, v in data.items(): - try: - # try removing a key from index which should - # be filled with new - ordered_keys.pop(v["order"]) - except IndexError: - pass - # add key into correct order - ordered_keys.insert(v["order"], k) - - # process ordered - for k in ordered_keys: - v = data[k] - tool_tip = v.get("toolTip", "") - if v["type"] == "dict": - # adding spacer between sections - self.content_layout.append(QtWidgets.QWidget(self)) - content_layout.addWidget(self.content_layout[-1]) - self.content_layout[-1].setObjectName("sectionHeadline") - - headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) - headline.addWidget(QtWidgets.QLabel(v["label"])) - - # adding nested layout with label - self.content_layout.append(QtWidgets.QWidget(self)) - self.content_layout[-1].setObjectName("sectionContent") - - nested_content_layout = QtWidgets.QFormLayout( - self.content_layout[-1]) - nested_content_layout.setObjectName("NestedContentLayout") - content_layout.addWidget(self.content_layout[-1]) - - # add nested key as label - data[k]["value"] = self.populate_widgets( - v["value"], nested_content_layout) - - if v["type"] == "section": - # adding spacer between sections - self.content_layout.append(QtWidgets.QWidget(self)) - content_layout.addWidget(self.content_layout[-1]) - self.content_layout[-1].setObjectName("sectionHeadline") - - headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) - headline.addWidget(QtWidgets.QLabel(v["label"])) - - # adding nested layout with label - self.content_layout.append(QtWidgets.QWidget(self)) - self.content_layout[-1].setObjectName("sectionContent") - - nested_content_layout = QtWidgets.QFormLayout( - self.content_layout[-1]) - nested_content_layout.setObjectName("NestedContentLayout") - content_layout.addWidget(self.content_layout[-1]) - - # add nested key as label - data[k]["value"] = self.populate_widgets( - v["value"], nested_content_layout) - - elif v["type"] == "QLineEdit": - data[k]["value"] = self.create_row( - content_layout, "QLineEdit", v["label"], - setText=v["value"], setToolTip=tool_tip) - elif v["type"] == "QComboBox": - data[k]["value"] = self.create_row( - content_layout, "QComboBox", v["label"], - addItems=v["value"], setToolTip=tool_tip) - elif v["type"] == "QCheckBox": - data[k]["value"] = self.create_row( - content_layout, "QCheckBox", v["label"], - setChecked=v["value"], setToolTip=tool_tip) - elif v["type"] == "QSpinBox": - data[k]["value"] = self.create_row( - content_layout, "QSpinBox", v["label"], - setRange=(0, 99999), - setValue=v["value"], - setToolTip=tool_tip) - return data - - -class Spacer(QtWidgets.QWidget): - def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - - self.setFixedHeight(height) - - real_spacer = QtWidgets.QWidget(self) - real_spacer.setObjectName("Spacer") - real_spacer.setFixedHeight(height) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(real_spacer) - - self.setLayout(layout) +SHARED_DATA_KEY = "ayon.resolve.instances" class ClipLoader: @@ -555,45 +300,47 @@ def remove(self, container): pass -class Creator(LegacyCreator): - """Creator class wrapper - """ - marker_color = "Purple" +class ResolveCreator(NewCreator): + """ Resolve Creator class wrapper""" - def __init__(self, *args, **kwargs): - super(Creator, self).__init__(*args, **kwargs) + marker_color = "Purple" + presets = {} - resolve_p_settings = get_current_project_settings().get("resolve") - self.presets = {} - if resolve_p_settings: - self.presets = resolve_p_settings["create"].get( - self.__class__.__name__, {}) + def apply_settings(self, project_settings): + resolve_create_settings = ( + project_settings.get("resolve", {}).get("create") + ) + self.presets = resolve_create_settings.get( + self.__class__.__name__, {} + ) + def create(self, subset_name, instance_data, pre_create_data): # adding basic current context resolve objects - self.project = lib.get_current_project() + self.project = lib.get_current_resolve_project() self.timeline = lib.get_current_timeline() - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection", False): self.selected = lib.get_current_timeline_items(filter=True) else: self.selected = lib.get_current_timeline_items(filter=False) - self.widget = CreatorWidget + # TODO: Add a way to store/imprint data + def get_pre_create_attr_defs(self): + return [ + BoolDef("use_selection", + label="Use selection", + default=True) + ] -class PublishClip: - """ - Convert a track item to publishable instance +# alias for backward compatibility +Creator = ResolveCreator # noqa - Args: - timeline_item (hiero.core.TrackItem): hiero track item object - kwargs (optional): additional data needed for rename=True (presets) - Returns: - hiero.core.TrackItem: hiero track item object with openpype tag +class PublishableClip: + """ + Convert a track item to publishable instance """ - vertical_clip_match = {} - tag_data = {} types = { "shot": "shot", "folder": "folder", @@ -609,7 +356,7 @@ class PublishClip: rename_default = False hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" - base_product_name_default = "" + variant_default = "" review_track_default = "< none >" product_type_default = "plate" count_from_default = 10 @@ -617,9 +364,39 @@ class PublishClip: vertical_sync_default = False driving_layer_default = "" - def __init__(self, cls, timeline_item_data, **kwargs): - # populate input cls attribute onto self.[attr] - self.__dict__.update(cls.__dict__) + # Define which keys of the pre create data should also be 'tag data' + tag_keys = { + # renameHierarchy + "hierarchy", + # hierarchyData + "folder", "episode", "sequence", "track", "shot", + # publish settings + "audio", "sourceResolution", + # shot attributes + "workfileFrameStart", "handleStart", "handleEnd" + } + + def __init__( + self, + timeline_item_data: dict, + pre_create_data: dict = None, + media_pool_folder: str = None, + rename_index: int = 0, + data: dict = None + ): + """ Initialize object + + Args: + timeline_item_data (dict): timeline item data + pre_create_data (dict): pre create data + media_pool_folder (str): media pool folder + rename_index (int): rename index + data (dict): additional data + + """ + self.rename_index = rename_index + self.vertical_clip_match = {} + self.tag_data = data or {} # get main parent objects self.timeline_item_data = timeline_item_data @@ -636,14 +413,11 @@ def __init__(self, cls, timeline_item_data, **kwargs): self.track_name = str(track_name).replace(" ", "_") self.track_index = int(timeline_item_data["track"]["index"]) - if kwargs.get("avalon"): - self.tag_data.update(kwargs["avalon"]) - # adding ui inputs if any - self.ui_inputs = kwargs.get("ui_inputs", {}) + self.pre_create_data = pre_create_data or {} # adding media pool folder if any - self.mp_folder = kwargs.get("mp_folder") + self.media_pool_folder = media_pool_folder # populate default data before we get other attributes self._populate_timeline_item_default_data() @@ -655,37 +429,37 @@ def __init__(self, cls, timeline_item_data, **kwargs): self._create_parents() def convert(self): + """ Convert track item to publishable instance. + + Returns: + timeline_item (resolve.TimelineItem): timeline item with imprinted + data in marker + """ # solve track item data and add them to tag data self._convert_to_tag_data() # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation - if (self.track_name in self.review_layer) and ( - self.driving_layer not in self.review_layer): + if ( + self.track_name in self.review_track and + self.hero_track not in self.review_track + ): return # deal with clip name new_name = self.tag_data.pop("newClipName") if self.rename: - self.tag_data["asset_name"] = new_name + self.tag_data["asset"] = new_name else: - self.tag_data["asset_name"] = self.ti_name - - # AYON unique identifier - folder_path = "/{}/{}".format( - self.tag_data["hierarchy"], - self.tag_data["asset_name"] - ) - self.tag_data["folder_path"] = folder_path + self.tag_data["asset"] = self.ti_name - # create new name for track item - if not lib.pype_marker_workflow: + if not constants.ayon_marker_workflow: # create compound clip workflow lib.create_compound_clip( self.timeline_item_data, - self.tag_data["asset_name"], - self.mp_folder + self.tag_data["asset"], + self.media_pool_folder ) # add timeline_item_data selection to tag @@ -719,39 +493,35 @@ def _populate_attributes(self): # define ui inputs if non gui mode was used self.shot_num = self.ti_index - # ui_inputs data or default values if gui was not used - self.rename = self.ui_inputs.get( - "clipRename", {}).get("value") or self.rename_default - self.clip_name = self.ui_inputs.get( - "clipName", {}).get("value") or self.clip_name_default - self.hierarchy = self.ui_inputs.get( - "hierarchy", {}).get("value") or self.hierarchy_default - self.hierarchy_data = self.ui_inputs.get( - "hierarchyData", {}).get("value") or \ - self.timeline_item_default_data.copy() - self.count_from = self.ui_inputs.get( - "countFrom", {}).get("value") or self.count_from_default - self.count_steps = self.ui_inputs.get( - "countSteps", {}).get("value") or self.count_steps_default - self.base_product_name = self.ui_inputs.get( - "productName", {}).get("value") or self.base_product_name_default - self.product_type = self.ui_inputs.get( - "productType", {}).get("value") or self.product_type_default - self.vertical_sync = self.ui_inputs.get( - "vSyncOn", {}).get("value") or self.vertical_sync_default - self.driving_layer = self.ui_inputs.get( - "vSyncTrack", {}).get("value") or self.driving_layer_default - self.review_track = self.ui_inputs.get( - "reviewTrack", {}).get("value") or self.review_track_default - - # build product name from layer name - if self.base_product_name == "": - self.base_product_name = self.track_name - - # create product name for publishing - self.product_name = ( - self.product_type + self.base_product_name.capitalize() - ) + # publisher ui attribute inputs or default values if gui was not used + def get(key): + """Shorthand access for code readability""" + return self.pre_create_data.get(key) + + self.rename = get("clipRename") or self.rename_default + self.clip_name = get("clipName") or self.clip_name_default + self.hierarchy = get("hierarchy") or self.hierarchy_default + self.count_from = get("countFrom") or self.count_from_default + self.count_steps = get("countSteps") or self.count_steps_default + self.variant = get("variant") or self.variant_default + self.product_type = get("productType") or self.product_type_default + self.vertical_sync = get("vSyncOn") or self.vertical_sync_default + self.hero_track = get("vSyncTrack") or self.driving_layer_default + self.review_media_track = ( + get("reviewTrack") or self.review_track_default) + + self.hierarchy_data = { + key: get(key) or self.timeline_item_default_data[key] + for key in ["folder", "episode", "sequence", "track", "shot"] + } + + # build subset name from layer name + if self.variant == "": + self.variant = self.track_name + + # create subset for publishing + # TODO: Use creator `get_subset_name` to correctly define name + self.product_name = self.product_type + self.variant.capitalize() def _replace_hash_to_expression(self, name, text): """ Replace hash with number in correct padding. """ @@ -762,37 +532,38 @@ def _replace_hash_to_expression(self, name, text): return new_text def _convert_to_tag_data(self): - """ Convert internal data to tag data. + """Convert internal data to tag data. Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes hero_track = True - self.review_layer = "" - if self.vertical_sync: - # check if track name is not in driving layer - if self.track_name not in self.driving_layer: - # if it is not then define vertical sync as None - hero_track = False + self.review_track = "" + + if self.vertical_sync and self.track_name not in self.hero_track: + hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index hierarchy_formatting_data = {} _data = self.timeline_item_default_data.copy() - if self.ui_inputs: + if self.pre_create_data: + # adding tag metadata from ui - for _k, _v in self.ui_inputs.items(): - if _v["target"] == "tag": - self.tag_data[_k] = _v["value"] + for _key, _value in self.pre_create_data.items(): + if _key in self.tag_keys: + self.tag_data[_key] = _value # driving layer is set as positive match if hero_track or self.vertical_sync: - # mark review layer - if self.review_track and ( - self.review_track not in self.review_track_default): - # if review layer is defined and not the same as default - self.review_layer = self.review_track + # mark review track + if ( + self.review_media_track + and self.review_media_track not in self.review_track_default # noqa + ): + # if review track is defined and not the same as default + self.review_track = self.review_media_track # shot num calculate if self.rename_index == 0: self.shot_num = self.count_from @@ -803,16 +574,16 @@ def _convert_to_tag_data(self): _data.update({"shot": self.shot_num}) # solve # in test to pythonic expression - for _k, _v in self.hierarchy_data.items(): - if "#" not in _v["value"]: + for _key, _value in self.hierarchy_data.items(): + if "#" not in _value: continue - self.hierarchy_data[ - _k]["value"] = self._replace_hash_to_expression( - _k, _v["value"]) + self.hierarchy_data[_key] = self._replace_hash_to_expression( + _key, _value + ) # fill up pythonic expresisons in hierarchy data - for k, _v in self.hierarchy_data.items(): - hierarchy_formatting_data[k] = _v["value"].format(**_data) + for _key, _value in self.hierarchy_data.items(): + hierarchy_formatting_data[_key] = _value.format(**_data) else: # if no gui mode then just pass default data hierarchy_formatting_data = self.hierarchy_data @@ -831,19 +602,17 @@ def _convert_to_tag_data(self): # driving layer is set as negative match for (_in, _out), hero_data in self.vertical_clip_match.items(): hero_data.update({"heroTrack": False}) - if _in != self.clip_in or _out != self.clip_out: - continue - - data_product_name = hero_data["productName"] - # add track index in case duplicity of names in hero data - if self.product_name in data_product_name: - hero_data["productName"] = self.product_name + str( - self.track_index) - # in case track name and product name is the same then add - if self.base_product_name == self.track_name: - hero_data["productName"] = self.product_name - # assign data to return hierarchy data to tag - tag_hierarchy_data = hero_data + if _in == self.clip_in and _out == self.clip_out: + data_subset = hero_data["product_name"] + # add track index in case duplicity of names in hero data + if self.product_name in data_subset: + hero_data["product_name"] = self.product_name + str( + self.track_index) + # in case track name and subset name is the same then add + if self.variant == self.track_name: + hero_data["product_name"] = self.product_name + # assign data to return hierarchy data to tag + tag_hierarchy_data = hero_data # add data to return data dict self.tag_data.update(tag_hierarchy_data) @@ -852,8 +621,8 @@ def _convert_to_tag_data(self): self.tag_data["uuid"] = str(uuid.uuid4()) # add review track only to hero track - if hero_track and self.review_layer: - self.tag_data.update({"reviewTrack": self.review_layer}) + if hero_track and self.review_track: + self.tag_data.update({"reviewTrack": self.review_track}) else: self.tag_data.update({"reviewTrack": None}) @@ -900,11 +669,80 @@ def _create_parents(self): parent = self._convert_to_entity(key) self.parents.append(parent) +# alias for backward compatibility +PublishClip = PublishableClip # noqa + + +class HiddenResolvePublishCreator(HiddenCreator): + host_name = "resolve" + settings_category = "resolve" + + def collect_instances(self): + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + for instance_data in instances_by_identifier[self.identifier]: + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + update_instances(update_list) + + def remove_instances(self, instances): + remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def _store_new_instance(self, new_instance): + """Resolve publisher specific method to store instance. + + Instance is stored into "workfile" of Resolve and also add it + to CreateContext. + + Args: + new_instance (CreatedInstance): Instance that should be stored. + """ + + # Host implementation of storing metadata about instance + HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + +class ResolvePublishCreator(Creator): + create_allow_context_change = True + host_name = "resolve" + settings_category = "resolve" + + def collect_instances(self): + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + for instance_data in instances_by_identifier[self.identifier]: + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + update_instances(update_list) + + def remove_instances(self, instances): + remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def _store_new_instance(self, new_instance): + """Resolve publisher specific method to store instance. + + Instance is stored into "workfile" of Resolve and also add it + to CreateContext. + + Args: + new_instance (CreatedInstance): Instance that should be stored. + """ + + # Host implementation of storing metadata about instance + HostContext.add_instance(new_instance.data_to_store()) + new_instance.mark_as_stored() -def get_representation_files(representation): - anatomy = Anatomy() - files = [] - for file_data in representation["files"]: - path = anatomy.fill_root(file_data["path"]) - files.append(path) - return files + # Add instance to current context + self._add_instance_to_context(new_instance) diff --git a/client/ayon_resolve/api/todo-rendering.py b/client/ayon_resolve/api/todo-rendering.py index 5238d76dec..265f922069 100644 --- a/client/ayon_resolve/api/todo-rendering.py +++ b/client/ayon_resolve/api/todo-rendering.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# TODO: convert this script to be usable with OpenPype +# TODO: convert this script to be usable with Ayon """ Example DaVinci Resolve script: Load a still from DRX file, apply the still to all clips in all timelines. diff --git a/client/ayon_resolve/api/workio.py b/client/ayon_resolve/api/workio.py index b6c2f63432..fc61d9bb10 100644 --- a/client/ayon_resolve/api/workio.py +++ b/client/ayon_resolve/api/workio.py @@ -4,7 +4,7 @@ from ayon_core.lib import Logger from .lib import ( get_project_manager, - get_current_project + get_current_resolve_project ) @@ -16,27 +16,28 @@ def file_extensions(): def has_unsaved_changes(): - get_project_manager().SaveProject() + project_manager = get_project_manager() + project_manager.SaveProject() return False def save_file(filepath): - pm = get_project_manager() + project_manager = get_project_manager() file = os.path.basename(filepath) fname, _ = os.path.splitext(file) - project = get_current_project() - name = project.GetName() + resolve_project = get_current_resolve_project() + name = resolve_project.GetName() response = False if name == "Untitled Project": - response = pm.CreateProject(fname) + response = project_manager.CreateProject(fname) log.info("New project created: {}".format(response)) - pm.SaveProject() + project_manager.SaveProject() elif name != fname: - response = project.SetName(fname) + response = resolve_project.SetName(fname) log.info("Project renamed: {}".format(response)) - exported = pm.ExportProject(fname, filepath) + exported = project_manager.ExportProject(fname, filepath) log.info("Project exported: {}".format(exported)) @@ -47,41 +48,41 @@ def open_file(filepath): from . import bmdvr - pm = get_project_manager() + project_manager = get_project_manager() page = bmdvr.GetCurrentPage() if page is not None: # Save current project only if Resolve has an active page, otherwise # we consider Resolve being in a pre-launch state (no open UI yet) - project = pm.GetCurrentProject() - print(f"Saving current project: {project}") - pm.SaveProject() + resolve_project = get_current_resolve_project() + print(f"Saving current resolve project: {resolve_project}") + project_manager.SaveProject() file = os.path.basename(filepath) fname, _ = os.path.splitext(file) try: # load project from input path - project = pm.LoadProject(fname) - log.info(f"Project {project.GetName()} opened...") + resolve_project = project_manager.LoadProject(fname) + log.info(f"Project {resolve_project.GetName()} opened...") except AttributeError: log.warning((f"Project with name `{fname}` does not exist! It will " f"be imported from {filepath} and then loaded...")) - if pm.ImportProject(filepath): + if project_manager.ImportProject(filepath): # load project from input path - project = pm.LoadProject(fname) - log.info(f"Project imported/loaded {project.GetName()}...") + resolve_project = project_manager.LoadProject(fname) + log.info(f"Project imported/loaded {resolve_project.GetName()}...") return True return False return True def current_file(): - pm = get_project_manager() + resolve_project = get_current_resolve_project() file_ext = file_extensions()[0] workdir_path = os.getenv("AYON_WORKDIR") - project = pm.GetCurrentProject() - project_name = project.GetName() + + project_name = resolve_project.GetName() file_name = project_name + file_ext # create current file path diff --git a/client/ayon_resolve/hooks/pre_resolve_setup.py b/client/ayon_resolve/hooks/pre_resolve_setup.py index ffd34d7b8d..ad1b96161d 100644 --- a/client/ayon_resolve/hooks/pre_resolve_setup.py +++ b/client/ayon_resolve/hooks/pre_resolve_setup.py @@ -18,12 +18,12 @@ class PreLaunchResolveSetup(PreLaunchHook): It adds $RESOLVE_SCRIPT_API/Modules to PYTHONPATH. Additionally it sets up the Python home for Python 3 based on the - RESOLVE_PYTHON3_HOME in the environment (usually defined in OpenPype's + RESOLVE_PYTHON3_HOME in the environment (usually defined in Ayon's Application environment for Resolve by the admin). For this it sets PYTHONHOME and PATH variables. It also defines: - - `RESOLVE_UTILITY_SCRIPTS_DIR`: Destination directory for OpenPype + - `RESOLVE_UTILITY_SCRIPTS_DIR`: Destination directory for Ayon Fusion scripts to be copied to for Resolve to pick them up. - `AYON_LOG_NO_COLORS` to True to ensure OP doesn't try to use logging with terminal colors as it fails in Resolve. diff --git a/client/ayon_resolve/otio/davinci_export.py b/client/ayon_resolve/otio/davinci_export.py index 5f11c81fc5..416f63654d 100644 --- a/client/ayon_resolve/otio/davinci_export.py +++ b/client/ayon_resolve/otio/davinci_export.py @@ -256,11 +256,11 @@ def add_otio_metadata(otio_item, media_pool_item, **kwargs): otio_item.metadata.update({key: value}) -def create_otio_timeline(resolve_project): +def create_otio_timeline(resolve_project, timeline=None): # get current timeline - self.project_fps = resolve_project.GetSetting("timelineFrameRate") - timeline = resolve_project.GetCurrentTimeline() + timeline = timeline or resolve_project.GetCurrentTimeline() + self.project_fps = timeline.GetSetting("timelineFrameRate") # convert timeline to otio otio_timeline = _create_otio_timeline( diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index da98c8de7d..75e3fd3d37 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -1,245 +1,234 @@ # from pprint import pformat -from ayon_resolve.api import plugin, lib +from ayon_resolve.api import plugin, lib, constants from ayon_resolve.api.lib import ( get_video_track_names, create_bin, ) +from ayon_core.pipeline.create import CreatorError, CreatedInstance +from ayon_core.lib import BoolDef, EnumDef, TextDef, UILabelDef, NumberDef -class CreateShotClip(plugin.Creator): +class CreateShotClip(plugin.ResolveCreator): """Publishable clip""" + identifier = "io.ayon.creators.resolve.clip" label = "Create Publishable Clip" product_type = "clip" icon = "film" defaults = ["Main"] - gui_tracks = get_video_track_names() - gui_name = "AYON publish attributes creator" - gui_info = "Define sequential rename and fill hierarchy data." - gui_inputs = { - "renameHierarchy": { - "type": "section", - "label": "Shot Hierarchy And Rename Settings", - "target": "ui", - "order": 0, - "value": { - "hierarchy": { - "value": "{folder}/{sequence}", - "type": "QLineEdit", - "label": "Shot Parent Hierarchy", - "target": "tag", - "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa - "order": 0}, - "clipRename": { - "value": False, - "type": "QCheckBox", - "label": "Rename clips", - "target": "ui", - "toolTip": "Renaming selected clips on fly", # noqa - "order": 1}, - "clipName": { - "value": "{sequence}{shot}", - "type": "QLineEdit", - "label": "Clip Name Template", - "target": "ui", - "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa - "order": 2}, - "countFrom": { - "value": 10, - "type": "QSpinBox", - "label": "Count sequence from", - "target": "ui", - "toolTip": "Set when the sequence number stafrom", # noqa - "order": 3}, - "countSteps": { - "value": 10, - "type": "QSpinBox", - "label": "Stepping number", - "target": "ui", - "toolTip": "What number is adding every new step", # noqa - "order": 4}, - } - }, - "hierarchyData": { - "type": "dict", - "label": "Shot Template Keywords", - "target": "tag", - "order": 1, - "value": { - "folder": { - "value": "shots", - "type": "QLineEdit", - "label": "{folder}", - "target": "tag", - "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 0}, - "episode": { - "value": "ep01", - "type": "QLineEdit", - "label": "{episode}", - "target": "tag", - "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 1}, - "sequence": { - "value": "sq01", - "type": "QLineEdit", - "label": "{sequence}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 2}, - "track": { - "value": "{_track_}", - "type": "QLineEdit", - "label": "{track}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 3}, - "shot": { - "value": "sh###", - "type": "QLineEdit", - "label": "{shot}", - "target": "tag", - "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 4} - } - }, - "verticalSync": { - "type": "section", - "label": "Vertical Synchronization Of Attributes", - "target": "ui", - "order": 2, - "value": { - "vSyncOn": { - "value": True, - "type": "QCheckBox", - "label": "Enable Vertical Sync", - "target": "ui", - "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa - "order": 0}, - "vSyncTrack": { - "value": gui_tracks, # noqa - "type": "QComboBox", - "label": "Hero track", - "target": "ui", - "toolTip": "Select driving track name which should be mastering all others", # noqa - "order": 1 - } - } - }, - "publishSettings": { - "type": "section", - "label": "Publish Settings", - "target": "ui", - "order": 3, - "value": { - "productName": { - "value": ["", "main", "bg", "fg", "bg", - "animatic"], - "type": "QComboBox", - "label": "Product Name", - "target": "ui", - "toolTip": "chose product name pattern, if is selected, name of track layer will be used", # noqa - "order": 0}, - "productType": { - "value": ["plate", "take"], - "type": "QComboBox", - "label": "Product type", - "target": "ui", "toolTip": "What use of this product is for", # noqa - "order": 1}, - "reviewTrack": { - "value": ["< none >"] + gui_tracks, - "type": "QComboBox", - "label": "Use Review Track", - "target": "ui", - "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa - "order": 2}, - "audio": { - "value": False, - "type": "QCheckBox", - "label": "Include audio", - "target": "tag", - "toolTip": "Process products with corresponding audio", # noqa - "order": 3}, - "sourceResolution": { - "value": False, - "type": "QCheckBox", - "label": "Source resolution", - "target": "tag", - "toolTip": "Is resolution taken from timeline or source?", # noqa - "order": 4}, - } - }, - "shotAttr": { - "type": "section", - "label": "Shot Attributes", - "target": "ui", - "order": 4, - "value": { - "workfileFrameStart": { - "value": 1001, - "type": "QSpinBox", - "label": "Workfiles Start Frame", - "target": "tag", - "toolTip": "Set workfile starting frame number", # noqa - "order": 0 - }, - "handleStart": { - "value": 0, - "type": "QSpinBox", - "label": "Handle start (head)", - "target": "tag", - "toolTip": "Handle at start of clip", # noqa - "order": 1 - }, - "handleEnd": { - "value": 0, - "type": "QSpinBox", - "label": "Handle end (tail)", - "target": "tag", - "toolTip": "Handle at end of clip", # noqa - "order": 2 - } - } - } - } + create_allow_context_change = False + create_allow_thumbnail = False + + def get_pre_create_attr_defs(self): + + def header_label(text): + return f"
{text}" + + tokens_help = """\nUsable tokens: + {_clip_}: name of used clip + {_track_}: name of parent track layer + {_sequence_}: name of parent sequence (timeline)""" + gui_tracks = get_video_track_names() + + # Project settings might be applied to this creator via + # the inherited `Creator.apply_settings` + presets = self.presets + + return [ + + BoolDef("use_selection", + label="Use only clips with Chocolate clip color", + tooltip=( + "When enabled only clips of Chocolate clip color are " + "considered.\n\n" + "Acts as a replacement to 'Use selection' because " + "Resolves API exposes no functionality to retrieve " + "the currently selected timeline items." + ), + default=True), + + # renameHierarchy + UILabelDef( + label=header_label("Shot Hierarchy And Rename Settings") + ), + TextDef( + "hierarchy", + label="Shot Parent Hierarchy", + tooltip="Parents folder for shot root folder, " + "Template filled with *Hierarchy Data* section", + default=presets.get("hierarchy", "{folder}/{sequence}"), + ), + BoolDef( + "clipRename", + label="Rename clips", + tooltip="Renaming selected clips on fly", + default=presets.get("clipRename", False), + ), + TextDef( + "clipName", + label="Clip Name Template", + tooltip="template for creating shot names, used for " + "renaming (use rename: on)", + default=presets.get("clipName", "{sequence}{shot}"), + ), + NumberDef( + "countFrom", + label="Count sequence from", + tooltip="Set where the sequence number starts from", + default=presets.get("countFrom", 10), + ), + NumberDef( + "countSteps", + label="Stepping number", + tooltip="What number is adding every new step", + default=presets.get("countSteps", 10), + ), + + # hierarchyData + UILabelDef( + label=header_label("Shot Template Keywords") + ), + TextDef( + "folder", + label="{folder}", + tooltip="Name of folder used for root of generated shots.\n" + f"{tokens_help}", + default=presets.get("folder", "shots"), + ), + TextDef( + "episode", + label="{episode}", + tooltip=f"Name of episode.\n{tokens_help}", + default=presets.get("episode", "ep01"), + ), + TextDef( + "sequence", + label="{sequence}", + tooltip=f"Name of sequence of shots.\n{tokens_help}", + default=presets.get("sequence", "sq01"), + ), + TextDef( + "track", + label="{track}", + tooltip=f"Name of timeline track.\n{tokens_help}", + default=presets.get("track", "{_track_}"), + ), + TextDef( + "shot", + label="{shot}", + tooltip="Name of shot. '#' is converted to padded number." + f"\n{tokens_help}", + default=presets.get("shot", "sh###"), + ), + + # verticalSync + UILabelDef( + label=header_label("Vertical Synchronization Of Attributes") + ), + BoolDef( + "vSyncOn", + label="Enable Vertical Sync", + tooltip="Switch on if you want clips above " + "each other to share its attributes", + default=presets.get("vSyncOn", True), + ), + EnumDef( + "vSyncTrack", + label="Hero track", + tooltip="Select driving track name which should " + "be mastering all others", + items=gui_tracks or [""], + ), + + # publishSettings + UILabelDef( + label=header_label("Publish Settings") + ), + EnumDef( + "variant", + label="Product Variant", + tooltip="Chose variant which will be then used for " + "product name, if " + "is selected, name of track layer will be used", + items=['', 'main', 'bg', 'fg', 'bg', 'animatic'], + ), + EnumDef( + "productType", + label="Product Type", + tooltip="How the product will be used", + items=['plate'], # it is prepared for more types + ), + EnumDef( + "reviewTrack", + label="Use Review Track", + tooltip="Generate preview videos on fly, if " + "'< none >' is defined nothing will be generated.", + items=['< none >'] + gui_tracks, + ), + BoolDef( + "audio", + label="Include audio", + tooltip="Process subsets with corresponding audio", + default=False, + ), + BoolDef( + "sourceResolution", + label="Source resolution", + tooltip="Is resoloution taken from timeline or source?", + default=False, + ), + + # shotAttr + UILabelDef( + label=header_label("Shot Attributes"), + ), + NumberDef( + "workfileFrameStart", + label="Workfiles Start Frame", + tooltip="Set workfile starting frame number", + default=presets.get("workfileFrameStart", 1001), + ), + NumberDef( + "handleStart", + label="Handle start (head)", + tooltip="Handle at start of clip", + default=presets.get("handleStart", 0), + ), + NumberDef( + "handleEnd", + label="Handle end (tail)", + tooltip="Handle at end of clip", + default=presets.get("handleEnd", 0), + ), + ] presets = None + rename_index = 0 - def process(self): - # get key pairs from presets and match it on ui inputs - for k, v in self.gui_inputs.items(): - if v["type"] in ("dict", "section"): - # nested dictionary (only one level allowed - # for sections and dict) - for _k, _v in v["value"].items(): - if self.presets.get(_k) is not None: - self.gui_inputs[k][ - "value"][_k]["value"] = self.presets[_k] - if self.presets.get(k): - self.gui_inputs[k]["value"] = self.presets[k] - - # open widget for plugins inputs - widget = self.widget(self.gui_name, self.gui_info, self.gui_inputs) - widget.exec_() + def create(self, subset_name, instance_data, pre_create_data): + super(CreateShotClip, self).create(subset_name, + instance_data, + pre_create_data) if len(self.selected) < 1: return - if not widget.result: - print("Operation aborted") - return + self.log.info(self.selected) - self.rename_add = 0 + if not self.timeline: + raise CreatorError( + "You must be in an active timeline to " + "create the publishable clips.\n\n" + "Go into a timeline and then reset the publisher." + ) - # get ui output for track name for vertical sync - v_sync_track = widget.result["vSyncTrack"]["value"] + self.log.debug(f"Selected: {self.selected}") - # sort selected trackItems by + # sort selected trackItems by vSync track sorted_selected_track_items = [] unsorted_selected_track_items = [] - print("_____ selected ______") - print(self.selected) + v_sync_track = pre_create_data.get("vSyncTrack", "") for track_item_data in self.selected: if track_item_data["track"]["name"] in v_sync_track: sorted_selected_track_items.append(track_item_data) @@ -248,25 +237,98 @@ def process(self): sorted_selected_track_items.extend(unsorted_selected_track_items) - # sequence attrs - sq_frame_start = self.timeline.GetStartFrame() - sq_markers = self.timeline.GetMarkers() - # create media bin for compound clips (trackItems) - mp_folder = create_bin(self.timeline.GetName()) - - kwargs = { - "ui_inputs": widget.result, - "avalon": self.data, - "mp_folder": mp_folder, - "sq_frame_start": sq_frame_start, - "sq_markers": sq_markers - } - print(kwargs) - for i, track_item_data in enumerate(sorted_selected_track_items): - self.rename_index = i - self.log.info(track_item_data) + media_pool_folder = create_bin(self.timeline.GetName()) + + instances = [] + for index, track_item_data in enumerate(sorted_selected_track_items): + self.log.info( + "Processing track item data: {}".format(track_item_data) + ) + # convert track item to timeline media pool item - track_item = plugin.PublishClip( - self, track_item_data, **kwargs).convert() - track_item.SetClipColor(lib.publish_clip_color) + publish_clip = plugin.PublishableClip( + track_item_data, + pre_create_data, + media_pool_folder, + rename_index=index, + data=instance_data + ) + + track_item = publish_clip.convert() + if track_item is None: + # Ignore input clips that do not convert into a track item + # from `PublishableClip.convert` + continue + + track_item.SetClipColor(constants.publish_clip_color) + + instance_data = copy.deepcopy(instance_data) + # TODO: here we need to replicate Traypublisher Editorial workflow + # and create shot, plate, review, and audio instances with own + # dedicated plugin + + # Create the Publisher instance + instance = CreatedInstance( + product_type=self.product_type, + product_name=publish_clip.product_name, + data=instance_data, + creator=self + ) + instance.transient_data["track_item"] = track_item + self._add_instance_to_context(instance) + + # self.imprint_instance_node(instance_node, + # data=instance.data_to_store()) + instances.append(instance) + return instances + + def collect_instances(self): + """Collect all created instances from current timeline.""" + selected_timeline_items = lib.get_current_timeline_items( + filter=True, selecting_color=constants.publish_clip_color) + + instances = [] + for timeline_item_data in selected_timeline_items: + timeline_item = timeline_item_data["clip"]["item"] + + # get openpype tag data + tag_data = lib.get_timeline_item_ayon_tag(timeline_item) + if not tag_data: + continue + + instance = CreatedInstance.from_existing(tag_data, self) + instance.transient_data["track_item"] = timeline_item + self._add_instance_to_context(instance) + + return instances + + def update_instances(self, update_list): + """Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and it's changes. + """ + for created_inst, _changes in update_list: + track_item = created_inst.transient_data["track_item"] + data = created_inst.data_to_store() + self.log.info(f"Storing data: {data}") + + lib.imprint(track_item, data) + + def remove_instances(self, instances): + """Remove instance marker from track item. + + Args: + instance(List[CreatedInstance]): Instance objects which should be + removed. + """ + for instance in instances: + track_item = instance.transient_data["track_item"] + + # removing instance by marker color + print(f"Removing instance: {track_item.GetName()}") + track_item.DeleteMarkersByColor(constants.ayon_marker_color) + + self._remove_instance_from_context(instance) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py new file mode 100644 index 0000000000..227a2ca500 --- /dev/null +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating workfiles.""" +from ayon_core.pipeline import CreatedInstance, AutoCreator +from ayon_core.client import get_asset_by_name + + +class CreateWorkfile(AutoCreator): + """Workfile auto-creator.""" + identifier = "io.ayon.creators.resolve.workfile" + label = "Workfile" + family = "workfile" + icon = "fa5.file" + + default_variant = "Main" + + def create(self): + + variant = self.default_variant + current_instance = next( + ( + instance for instance in self.create_context.instances + if instance.creator_identifier == self.identifier + ), None) + + project_name = self.project_name + asset_name = self.create_context.get_current_asset_name() + task_name = self.create_context.get_current_task_name() + host_name = self.create_context.host_name + + if current_instance is None: + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant, + } + data.update( + self.get_dynamic_data( + variant, task_name, asset_doc, + project_name, host_name, current_instance) + ) + self.log.info("Auto-creating workfile instance...") + current_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(current_instance) + elif ( + current_instance["asset"] != asset_name + or current_instance["task"] != task_name + ): + # Update instance context if is not the same + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + current_instance["asset"] = asset_name + current_instance["task"] = task_name + current_instance["subset"] = subset_name + + def collect_instances(self): + # TODO: Implement + pass + + def update_instances(self, update_list): + # TODO: Implement + # This needs to be implemented to allow persisting any instance + # data on resets. We'll need to decide where to store workfile + # instance data reliably. Likely metadata on the *current project*? + pass diff --git a/client/ayon_resolve/plugins/load/load_clip.py b/client/ayon_resolve/plugins/load/load_clip.py index 7e3a5a254e..bd1847c95a 100644 --- a/client/ayon_resolve/plugins/load/load_clip.py +++ b/client/ayon_resolve/plugins/load/load_clip.py @@ -154,13 +154,13 @@ def remove(self, container): timeline.DeleteClips([timeline_item]) else: # Resolve versions older than 18.5 can't delete clips via API - # so all we can do is just remove the pype marker to 'untag' it - if lib.get_pype_marker(timeline_item): - # Note: We must call `get_pype_marker` because - # `delete_pype_marker` uses a global variable set by - # `get_pype_marker` to delete the right marker + # so all we can do is just remove the ayon marker to 'untag' it + if lib.get_ayon_marker(timeline_item): + # Note: We must call `get_ayon_marker` because + # `delete_ayon_marker` uses a global variable set by + # `get_ayon_marker` to delete the right marker # TODO: Improve code to avoid the global `temp_marker_frame` - lib.delete_pype_marker(timeline_item) + lib.delete_ayon_marker(timeline_item) # if media pool item has no remaining usages left # remove it from the media pool diff --git a/client/ayon_resolve/plugins/publish/collect_current_project.py b/client/ayon_resolve/plugins/publish/collect_current_project.py new file mode 100644 index 0000000000..dbac3d0635 --- /dev/null +++ b/client/ayon_resolve/plugins/publish/collect_current_project.py @@ -0,0 +1,32 @@ +import pyblish.api + +from ayon_core.hosts.resolve import api as rapi +from ayon_core.hosts.resolve.otio import davinci_export + + +class CollectResolveProject(pyblish.api.ContextPlugin): + """Collect the current Resolve project and current timeline data""" + + label = "Collect Project and Current Timeline" + order = pyblish.api.CollectorOrder - 0.499 + + def process(self, context): + resolve_project = rapi.get_current_resolve_project() + timeline = resolve_project.GetCurrentTimeline() + fps = timeline.GetSetting("timelineFrameRate") + + video_tracks = rapi.get_video_track_names() + + # adding otio timeline to context + otio_timeline = davinci_export.create_otio_timeline(resolve_project) + + # update context with main project attributes + context.data.update({ + # project + "activeProject": resolve_project, + "currentFile": resolve_project.GetName(), + # timeline + "otioTimeline": otio_timeline, + "videoTracks": video_tracks, + "fps": fps, + }) diff --git a/client/ayon_resolve/plugins/publish/extract_workfile.py b/client/ayon_resolve/plugins/publish/extract_workfile.py index 77d14ccdc5..1a9477b720 100644 --- a/client/ayon_resolve/plugins/publish/extract_workfile.py +++ b/client/ayon_resolve/plugins/publish/extract_workfile.py @@ -24,9 +24,8 @@ def process(self, instance): project = instance.context.data["activeProject"] staging_dir = self.staging_dir(instance) - resolve_workfile_ext = ".drp" - drp_file_name = name + resolve_workfile_ext - + ext = ".drp" + drp_file_name = name + ext drp_file_path = os.path.normpath( os.path.join(staging_dir, drp_file_name)) @@ -36,17 +35,18 @@ def process(self, instance): # create drp workfile representation representation_drp = { - 'name': resolve_workfile_ext[1:], - 'ext': resolve_workfile_ext[1:], + 'name': ext.lstrip("."), + 'ext': ext.lstrip("."), 'files': drp_file_name, "stagingDir": staging_dir, } - - instance.data["representations"].append(representation_drp) + representations = instance.data.setdefault("representations", []) + representations.append(representation_drp) # add sourcePath attribute to instance if not instance.data.get("sourcePath"): instance.data["sourcePath"] = drp_file_path - self.log.info("Added Resolve file representation: {}".format( - representation_drp)) + self.log.debug( + "Added Resolve file representation: {}".format(representation_drp) + ) diff --git a/client/ayon_resolve/plugins/publish/precollect_instances.py b/client/ayon_resolve/plugins/publish/precollect_instances.py index e2b6e7ba37..f3307eb3ee 100644 --- a/client/ayon_resolve/plugins/publish/precollect_instances.py +++ b/client/ayon_resolve/plugins/publish/precollect_instances.py @@ -3,10 +3,12 @@ import pyblish from ayon_core.pipeline import AYON_INSTANCE_ID, AVALON_INSTANCE_ID +from ayon_resolve.api.constants import ( + publish_clip_color +) from ayon_resolve.api.lib import ( get_current_timeline_items, - get_timeline_item_pype_tag, - publish_clip_color, + get_timeline_item_ayon_tag, get_publish_attribute, get_otio_clip_instance_data, ) @@ -33,8 +35,8 @@ def process(self, context): data = {} timeline_item = timeline_item_data["clip"]["item"] - # get pype tag data - tag_data = get_timeline_item_pype_tag(timeline_item) + # get ayon tag data + tag_data = get_timeline_item_ayon_tag(timeline_item) self.log.debug(f"__ tag_data: {pformat(tag_data)}") if not tag_data: diff --git a/client/ayon_resolve/plugins/publish/precollect_workfile.py b/client/ayon_resolve/plugins/publish/precollect_workfile.py deleted file mode 100644 index a388d4bc59..0000000000 --- a/client/ayon_resolve/plugins/publish/precollect_workfile.py +++ /dev/null @@ -1,54 +0,0 @@ -import pyblish.api -from pprint import pformat - -from ayon_core.pipeline import get_current_folder_path - -from ayon_resolve import api as rapi -from ayon_resolve.otio import davinci_export - - -class PrecollectWorkfile(pyblish.api.ContextPlugin): - """Precollect the current working file into context""" - - label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.5 - - def process(self, context): - current_folder_path = get_current_folder_path() - folder_name = current_folder_path.split("/")[-1] - - product_name = "workfileMain" - project = rapi.get_current_project() - fps = project.GetSetting("timelineFrameRate") - video_tracks = rapi.get_video_track_names() - - # adding otio timeline to context - otio_timeline = davinci_export.create_otio_timeline(project) - - instance_data = { - "name": "{}_{}".format(folder_name, product_name), - "label": "{} {}".format(current_folder_path, product_name), - "item": project, - "folderPath": current_folder_path, - "productName": product_name, - "productType": "workfile", - "family": "workfile", - "families": [] - } - - # create instance with workfile - instance = context.create_instance(**instance_data) - - # update context with main project attributes - context_data = { - "activeProject": project, - "otioTimeline": otio_timeline, - "videoTracks": video_tracks, - "currentFile": project.GetName(), - "fps": fps, - } - context.data.update(context_data) - - self.log.info("Creating instance: {}".format(instance)) - self.log.debug("__ instance.data: {}".format(pformat(instance.data))) - self.log.debug("__ context_data: {}".format(pformat(context_data))) diff --git a/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib b/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib index 22253390a3..ecc75946b5 100644 --- a/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib +++ b/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib @@ -1,4 +1,4 @@ --- Run OpenPype's Python launch script for resolve +-- Run Ayon's Python launch script for resolve function file_exists(name) local f = io.open(name, "r") return f ~= nil and io.close(f) @@ -12,7 +12,7 @@ if ayon_startup_script ~= nil then if file_exists(script) then -- We must use RunScript to ensure it runs in a separate -- process to Resolve itself to avoid a deadlock for - -- certain imports of OpenPype libraries or Qt + -- certain imports of Ayon libraries or Qt print("Running launch script: " .. script) fusion:RunScript(script) else diff --git a/client/ayon_resolve/utility_scripts/develop/OTIO_export.py b/client/ayon_resolve/utility_scripts/develop/OTIO_export.py index 4572d1354d..27147e5500 100644 --- a/client/ayon_resolve/utility_scripts/develop/OTIO_export.py +++ b/client/ayon_resolve/utility_scripts/develop/OTIO_export.py @@ -57,9 +57,9 @@ def _close_window(event): def _export_button(event): pm = resolve.GetProjectManager() - project = pm.GetCurrentProject() - timeline = project.GetCurrentTimeline() - otio_timeline = otio_export.create_otio_timeline(project) + resolve_project = pm.GetCurrentProject() + timeline = resolve_project.GetCurrentTimeline() + otio_timeline = otio_export.create_otio_timeline(resolve_project) otio_path = os.path.join( itm["exportfilebttn"].Text, timeline.GetName() + ".otio") From 0ea95c72badf8880c8d5fc63b1a2803cc1803d63 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jun 2024 15:12:13 +0200 Subject: [PATCH 02/58] Refactor menu stylesheet loading, update plugin creation logic - Removed the function for loading stylesheets in the menu. - Refactored plugin creation logic to use new API methods and data structures. --- client/ayon_resolve/api/menu.py | 11 --- client/ayon_resolve/api/plugin.py | 2 - .../plugins/create/create_workfile.py | 90 +++++++++---------- 3 files changed, 44 insertions(+), 59 deletions(-) diff --git a/client/ayon_resolve/api/menu.py b/client/ayon_resolve/api/menu.py index fbe91811db..6778119091 100644 --- a/client/ayon_resolve/api/menu.py +++ b/client/ayon_resolve/api/menu.py @@ -11,17 +11,6 @@ MENU_LABEL = os.environ["AYON_MENU_LABEL"] -def load_stylesheet(): - path = os.path.join(os.path.dirname(__file__), "menu_style.qss") - if not os.path.exists(path): - print("Unable to load stylesheet, file not found in resources") - return "" - - with open(path, "r") as file_stream: - stylesheet = file_stream.read() - return stylesheet - - class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): super(Spacer, self).__init__(*args, **kwargs) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index ba6ac9719e..55c9c45ff0 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -3,8 +3,6 @@ import qargparse -from ayon_core.pipeline.context_tools import get_current_project_asset - from ayon_core.lib import BoolDef from ayon_core.pipeline import ( diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 227a2ca500..2a8183da7b 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -1,67 +1,65 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" -from ayon_core.pipeline import CreatedInstance, AutoCreator -from ayon_core.client import get_asset_by_name +import ayon_api +from ayon_core.pipeline import ( + AutoCreator, + CreatedInstance, +) class CreateWorkfile(AutoCreator): """Workfile auto-creator.""" + settings_category = "resolve" + identifier = "io.ayon.creators.resolve.workfile" label = "Workfile" - family = "workfile" - icon = "fa5.file" + product_type = "workfile" default_variant = "Main" - def create(self): + def collect_instances(self): variant = self.default_variant - current_instance = next( - ( - instance for instance in self.create_context.instances - if instance.creator_identifier == self.identifier - ), None) - - project_name = self.project_name - asset_name = self.create_context.get_current_asset_name() + project_name = self.create_context.get_current_project_name() + folder_path = self.create_context.get_current_folder_path() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name - if current_instance is None: - asset_doc = get_asset_by_name(project_name, asset_name) - subset_name = self.get_subset_name( - variant, task_name, asset_doc, project_name, host_name - ) - data = { - "asset": asset_name, - "task": task_name, - "variant": variant, - } - data.update( - self.get_dynamic_data( - variant, task_name, asset_doc, - project_name, host_name, current_instance) + folder_entity = ayon_api.get_folder_by_path( + project_name, folder_path) + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + product_name = self.get_product_name( + project_name, + folder_entity, + task_entity, + self.default_variant, + host_name, + ) + data = { + "folderPath": folder_path, + "task": task_name, + "variant": variant, + } + data.update( + self.get_dynamic_data( + variant, + task_name, + folder_entity, + project_name, + host_name, + False, ) - self.log.info("Auto-creating workfile instance...") - current_instance = CreatedInstance( - self.family, subset_name, data, self - ) - self._add_instance_to_context(current_instance) - elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name - ): - # Update instance context if is not the same - asset_doc = get_asset_by_name(project_name, asset_name) - subset_name = self.get_subset_name( - variant, task_name, asset_doc, project_name, host_name - ) - current_instance["asset"] = asset_name - current_instance["task"] = task_name - current_instance["subset"] = subset_name + ) + self.log.info("Auto-creating workfile instance...") + current_instance = CreatedInstance( + self.product_type, product_name, data, self) + self._add_instance_to_context(current_instance) - def collect_instances(self): - # TODO: Implement + def create(self, options=None): + # no need to create if it is created + # in `collect_instances` pass def update_instances(self, update_list): From 1cd7dcd0442101f2970eb92243445e90a448e02e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jun 2024 12:15:50 +0200 Subject: [PATCH 03/58] Update API documentation and keyframe mode information. Remove outdated content related to previous versions. --- .../ayon_resolve/RESOLVE_API_v19.0B-build20.txt | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt b/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt index 7deb0743f6..d9207c9137 100644 --- a/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt +++ b/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt @@ -1,8 +1,5 @@ -<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt -Updated as of 18 December 2023 -======== + Last Updated: 1 April 2024 ->>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. @@ -106,11 +103,8 @@ Resolve ExportRenderPreset(presetName, exportPath) --> Bool # Export a preset to a given path (string) if presetName(string) exists. ImportBurnInPreset(presetPath) --> Bool # Import a data burn in preset from a given presetPath (string) ExportBurnInPreset(presetName, exportPath) --> Bool # Export a data burn in preset to a given path (string) if presetName (string) exists. -<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt -======== GetKeyframeMode() --> keyframeMode # Returns the currently set keyframe mode (int). Refer to section 'Keyframe Mode information' below for details. SetKeyframeMode(keyframeMode) --> Bool # Returns True when 'keyframeMode'(enum) is successfully set. Refer to section 'Keyframe Mode information' below for details. ->>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt ProjectManager ArchiveProject(projectName, @@ -370,10 +364,7 @@ Timeline # Returns True on success, False otherwise. DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. ConvertTimelineToStereo() --> Bool # Converts timeline to stereo. Returns True if successful; False otherwise. -<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt -======== GetNodeGraph() --> Graph # Returns the timeline's node graph object. ->>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt TimelineItem GetName() --> string # Returns the item name. @@ -489,8 +480,7 @@ Beside primitive data types, Resolve's Python API mainly uses list and dict data As Lua does not support list and dict data structures, the Lua API implements "list" as a table with indices, e.g. { [1] = listValue1, [2] = listValue2, ... }. Similarly the Lua API implements "dict" as a table with the dictionary key as first element, e.g. { [dictKey1] = dictValue1, [dictKey2] = dictValue2, ... }. -<<<<<<<< HEAD:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v18.6.5-build7.txt -======== + Keyframe Mode information ------------------------- This section covers additional notes for the functions Resolve.GetKeyframeMode() and Resolve.SetKeyframeMode(keyframeMode). @@ -502,7 +492,6 @@ This section covers additional notes for the functions Resolve.GetKeyframeMode() Integer values returned by Resolve.GetKeyframeMode() will correspond to the enums above. ->>>>>>>> develop:server_addon/resolve/client/ayon_resolve/RESOLVE_API_v19.0B-build20.txt Cloud Projects Settings -------------------------------------- This section covers additional notes for the functions "ProjectManager:CreateCloudProject," "ProjectManager:ImportCloudProject," and "ProjectManager:RestoreCloudProject" From 3ba67bede5e58dc94030bc8df580443b8b7c091c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jun 2024 12:25:42 +0200 Subject: [PATCH 04/58] Refactor otio file handling functions, add temp dir logic. - Refactored function names and descriptions for clarity - Added new function to export timeline otio files - Improved temporary directory handling for otio files --- client/ayon_resolve/api/lib.py | 53 ++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 5295c3e181..1368043daa 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -958,8 +958,8 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): return None -def get_timeline_otio_filepath(project_name, anatomy=None, timeline=None): - """Get timeline otio filepath. +def get_otio_temp_dir(project_name, anatomy=None, timeline=None) -> str: + """Get otio temporary directory. Args: project_name (str): ayon project name @@ -969,33 +969,48 @@ def get_timeline_otio_filepath(project_name, anatomy=None, timeline=None): Returns: str: temporary otio filepath """ - from . import bmdvr resolve_project = get_current_resolve_project() - timeline = resolve_project.GetCurrentTimeline() + timeline = timeline or resolve_project.GetCurrentTimeline() timeline_name = timeline.GetName() # get custom staging dir custom_temp_dir = create_custom_tempdir(project_name, anatomy) staging_dir = os.path.normpath( - tempfile.mkdtemp( - prefix="resolve_otio_tmp_", - dir=custom_temp_dir - ) + tempfile.mkdtemp(prefix="resolve_otio_tmp_", dir=custom_temp_dir) + ) + return os.path.join( + staging_dir, f"{timeline_name}.otio" ) - filename = os.path.join(staging_dir, f"{timeline_name}.otio") - # Native otio export is available from Resolve 18.5 - # [major, minor, patch, build, suffix] - resolve_version = bmdvr.GetVersion() - if resolve_version[0] < 18 or resolve_version[1] < 5: - # if it is lower then use ayon's otio exporter - otio_timeline = otio_export.create_otio_timeline( - resolve_project, timeline=timeline) - otio_export.write_to_file(otio_timeline, filename) - timeline.Export(filename, bmdvr.EXPORT_OTIO) +def export_timeline_otio(timeline, filepath): + """Get timeline otio filepath. + + Only supported from Resolve 19.5 + + Example: + # Native otio export is available from Resolve 18.5 + # [major, minor, patch, build, suffix] + resolve_version = bmdvr.GetVersion() + if resolve_version[0] < 18 or resolve_version[1] < 5: + # if it is lower then use ayon's otio exporter + otio_timeline = otio_export.create_otio_timeline( + resolve_project, timeline=timeline) + otio_export.write_to_file(otio_timeline, filepath) + else: + # use native otio export + export_timeline_otio(timeline, filepath) + + Args: + timeline (resolve.Timeline): resolve's object + filepath (str): otio file path + + Returns: + str: temporary otio filepath + """ + from . import bmdvr - return filename + timeline.Export(filepath, bmdvr.EXPORT_OTIO) def get_reformated_path(path, padded=False, first=False): From 54fcda2b76657ec213a9b4ed312785a74eedd8ff Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jun 2024 12:29:58 +0200 Subject: [PATCH 05/58] Refactor get_otio_temp_dir to handle missing timeline. Adjusted get_otio_temp_dir to set timeline if not provided, throwing error if none found. --- client/ayon_resolve/api/lib.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 1368043daa..532b2f6568 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -970,7 +970,12 @@ def get_otio_temp_dir(project_name, anatomy=None, timeline=None) -> str: str: temporary otio filepath """ resolve_project = get_current_resolve_project() - timeline = timeline or resolve_project.GetCurrentTimeline() + + if timeline is None: + timeline = resolve_project.GetCurrentTimeline() + if not timeline: + raise RuntimeError("No current timeline") + timeline_name = timeline.GetName() # get custom staging dir From 242e2551963a155a2f6f230106460e968dc5a02c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jun 2024 12:35:11 +0200 Subject: [PATCH 06/58] reverting latest commit changes partly --- client/ayon_resolve/api/lib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 532b2f6568..dea09c9291 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -291,8 +291,9 @@ def create_timeline_item( # get all variables resolve_project = get_current_resolve_project() media_pool = resolve_project.GetMediaPool() - _clip_property = media_pool_item.GetClipProperty - clip_name = _clip_property("File Name") + clip_name = media_pool_item.GetClipProperty("File Name") + timeline = timeline or get_current_timeline() + timeline = timeline or get_current_timeline() # timing variables From fc5c51902b225ffc3f58ae4ed278d6877a774357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 14 Jun 2024 12:33:00 +0200 Subject: [PATCH 07/58] Update server_addon/resolve/client/ayon_resolve/api/lib.py Co-authored-by: Roy Nieterau --- client/ayon_resolve/api/lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index dea09c9291..d9ff07b6a8 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -294,8 +294,6 @@ def create_timeline_item( clip_name = media_pool_item.GetClipProperty("File Name") timeline = timeline or get_current_timeline() - timeline = timeline or get_current_timeline() - # timing variables if all([timeline_in, source_start, source_end]): fps = timeline.GetSetting("timelineFrameRate") From 9112854f39207a9e54c21e95970e20276d16bb0d Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 21 Aug 2024 16:33:10 -0400 Subject: [PATCH 08/58] Fix bugs with old implementation. --- client/ayon_resolve/api/pipeline.py | 3 ++- client/ayon_resolve/api/plugin.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 6ae7c3468f..ddff1b0699 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -26,6 +26,7 @@ IPublishHost ) +from . import constants from . import lib from .utils import get_resolve_module from .workio import ( @@ -161,7 +162,7 @@ def ls(): # Media Pool instances from Load Media loader for clip in lib.iter_all_media_pool_clips(): - data = clip.GetMetadata(lib.pype_tag_name) + data = clip.GetMetadata(constants.ayon_tag_name) if not data: continue data = json.loads(data) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 8568620a4c..c825b6035b 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -652,7 +652,7 @@ def _convert_to_entity(self, key): return { "folder_type": folder_type, - "entity_name": self.hierarchy_data[key]["value"].format( + "entity_name": self.hierarchy_data[key].format( **self.timeline_item_default_data ) } From 113df3653045b693c4b860901fd934727d7a1f23 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 27 Aug 2024 10:45:52 -0400 Subject: [PATCH 09/58] Shot export functional. --- client/ayon_resolve/api/__init__.py | 4 + client/ayon_resolve/api/lib.py | 91 +++++++++----- .../plugins/create/create_shot_clip.py | 113 ++++++++++++++---- .../publish/collect_current_project.py | 17 ++- .../plugins/publish/precollect_instances.py | 2 + .../plugins/publish/precollect_shots.py | 69 +++++++++++ 6 files changed, 238 insertions(+), 58 deletions(-) create mode 100644 client/ayon_resolve/plugins/publish/precollect_shots.py diff --git a/client/ayon_resolve/api/__init__.py b/client/ayon_resolve/api/__init__.py index 50df9aea2d..b92091d066 100644 --- a/client/ayon_resolve/api/__init__.py +++ b/client/ayon_resolve/api/__init__.py @@ -21,6 +21,8 @@ get_current_timeline, get_any_timeline, get_new_timeline, + export_timeline_otio_to_file, + export_timeline_otio, create_bin, get_media_pool_item, create_media_pool_item, @@ -98,6 +100,8 @@ "get_current_timeline", "get_any_timeline", "get_new_timeline", + "export_timeline_otio_to_file", + "export_timeline_otio", "create_bin", "get_media_pool_item", "create_media_pool_item", diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 0bf1052aa9..9c259b5eb6 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -4,13 +4,15 @@ import contextlib import tempfile from typing import List, Dict, Any -from opentimelineio import opentime + +import opentimelineio as otio from ayon_core.lib import Logger from ayon_core.pipeline.editorial import ( is_overlapping_otio_ranges, frames_to_timecode ) +from ayon_core.pipeline.context_tools import get_current_project_name from ayon_core.pipeline.tempdir import create_custom_tempdir from . import constants @@ -680,12 +682,12 @@ def create_compound_clip(clip_data, name, folder): rate = float(_mp_props("FPS")) # source rational times - mp_in_rc = opentime.RationalTime((ci_l_offset), rate) - mp_out_rc = opentime.RationalTime((ci_l_offset + ci_duration - 1), rate) + mp_in_rc = otio.opentime.RationalTime((ci_l_offset), rate) + mp_out_rc = otio.opentime.RationalTime((ci_l_offset + ci_duration - 1), rate) # get frame in and out for clip swapping - in_frame = opentime.to_frames(mp_in_rc) - out_frame = opentime.to_frames(mp_out_rc) + in_frame = otio.opentime.to_frames(mp_in_rc) + out_frame = otio.opentime.to_frames(mp_out_rc) # keep original sequence tl_origin = timeline @@ -939,7 +941,12 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): timeline_range = create_otio_time_range_from_timeline_item_data( timeline_item_data) - for otio_clip in otio_timeline.each_clip(): + try: + all_clips = otio_timeline.each_clip() + except AttributeError: # OpenTimelineIO >= 0.16.0 + all_clips = otio_timeline.find_clips() + + for otio_clip in all_clips: track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if track_name not in track_name: @@ -958,8 +965,8 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): return None -def get_otio_temp_dir(project_name, anatomy=None, timeline=None) -> str: - """Get otio temporary directory. +def _get_otio_temp_file(project_name=None, anatomy=None, timeline=None) -> str: + """Get otio temporary export file. Args: project_name (str): ayon project name @@ -969,9 +976,10 @@ def get_otio_temp_dir(project_name, anatomy=None, timeline=None) -> str: Returns: str: temporary otio filepath """ - resolve_project = get_current_resolve_project() + project_name = project_name or get_current_project_name() if timeline is None: + resolve_project = get_current_resolve_project() timeline = resolve_project.GetCurrentTimeline() if not timeline: raise RuntimeError("No current timeline") @@ -988,34 +996,59 @@ def get_otio_temp_dir(project_name, anatomy=None, timeline=None) -> str: ) -def export_timeline_otio(timeline, filepath): - """Get timeline otio filepath. - - Only supported from Resolve 19.5 - - Example: - # Native otio export is available from Resolve 18.5 - # [major, minor, patch, build, suffix] - resolve_version = bmdvr.GetVersion() - if resolve_version[0] < 18 or resolve_version[1] < 5: - # if it is lower then use ayon's otio exporter - otio_timeline = otio_export.create_otio_timeline( - resolve_project, timeline=timeline) - otio_export.write_to_file(otio_timeline, filepath) - else: - # use native otio export - export_timeline_otio(timeline, filepath) +def export_timeline_otio_to_file(timeline, filepath): + """Export timeline as otio filepath. Args: - timeline (resolve.Timeline): resolve's object + timeline (resolve.Timeline): resolve's timeline filepath (str): otio file path Returns: str: temporary otio filepath """ - from . import bmdvr + try: + from . import bmdvr + raise AttributeError("TODO investigate export with metadata") + timeline.Export(filepath, bmdvr.EXPORT_OTIO) - timeline.Export(filepath, bmdvr.EXPORT_OTIO) + except Exception as error: + log.debug( + "Cannot use native OTIO export (%r)." + "Default to AYON own implementation.", + error + ) + otio_timeline = otio_export.create_otio_timeline( + get_current_resolve_project(), + timeline=timeline + ) + otio_export.write_to_file(otio_timeline, filepath) + + + +def export_timeline_otio(timeline): + """ Export timeline as otio. + + Args: + timeline (resolve.Timeline): resolve's timeline + + Returns: + otio_timeline (otio.Timeline): Otio timeline. + """ + # DaVinci Resolve <= 18.5 + # Legacy export (slower) through AYON. + if not hasattr(timeline, "Export"): + return otio_export.create_otio_timeline( + get_current_resolve_project(), + timeline=timeline + ) + + # DaVinci Resolve >= 18.5 + # Force export through a temporary file (native) + temp_otio_file = _get_otio_temp_file(timeline=timeline) + export_timeline_otio_to_file(timeline, temp_otio_file) + otio_timeline = otio.adapters.read_from_file(temp_otio_file) + + return otio_timeline def get_reformated_path(path, padded=False, first=False): diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 75e3fd3d37..e8c429b0c5 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -1,4 +1,5 @@ -# from pprint import pformat +import copy + from ayon_resolve.api import plugin, lib, constants from ayon_resolve.api.lib import ( get_video_track_names, @@ -8,16 +9,72 @@ from ayon_core.lib import BoolDef, EnumDef, TextDef, UILabelDef, NumberDef + + + +class _ResolveInstanceCreator(plugin.HiddenResolvePublishCreator): + """Wrapper class for shot product. + """ + + def create(self, instance_data, _): + """Return a new CreateInstance for new shot from Resolve. + + Args: + instance_data (dict): global data from original instance + + Return: + CreatedInstance: The created instance object for the new shot. + """ + hierarchy_path = ( + f'/{instance_data["hierarchy"]}/' + f'{instance_data["hierarchyData"]["shot"]}' + ) + instance_data.update({ + "productName": "shotMain", + "label": f"{hierarchy_path} {self.product_type}", + "productType": self.product_type, + "hierarchy_path": hierarchy_path, + "shotName": instance_data["hierarchyData"]["shot"] + }) + + new_instance = CreatedInstance( + self.product_type, instance_data["productName"], instance_data, self + ) + self._store_new_instance(new_instance) + return new_instance + + +class ResolveShotInstanceCreator(_ResolveInstanceCreator): + """Shot product. + """ + identifier = "io.ayon.creators.resolve.shot" + product_type = "shot" + label = "Editorial Shot" + + +class EditorialReviewInstanceCreator(_ResolveInstanceCreator): + """Review product type class + + Review representation instance. + """ + identifier = "io.ayon.creators.resolve.review" + product_type = "review" + label = "Editorial Review" + + class CreateShotClip(plugin.ResolveCreator): """Publishable clip""" identifier = "io.ayon.creators.resolve.clip" label = "Create Publishable Clip" - product_type = "clip" + product_type = "editorial" icon = "film" defaults = ["Main"] - create_allow_context_change = False +# create_allow_context_change = False +# TODO: explain consequence on folderPath +# https://github.com/ynput/ayon-core/blob/6a07de6eb904c139f6d346fd6f2a7d5042274c71/client/ayon_core/tools/publisher/widgets/create_widget.py#L732 + create_allow_thumbnail = False def get_pre_create_attr_defs(self): @@ -211,11 +268,6 @@ def create(self, subset_name, instance_data, pre_create_data): instance_data, pre_create_data) - if len(self.selected) < 1: - return - - self.log.info(self.selected) - if not self.timeline: raise CreatorError( "You must be in an active timeline to " @@ -223,7 +275,22 @@ def create(self, subset_name, instance_data, pre_create_data): "Go into a timeline and then reset the publisher." ) - self.log.debug(f"Selected: {self.selected}") + if not self.selected: + if pre_create_data.get("use_selection", False): + raise CreatorError( + "No Chocolate-colored clips found from " + "timeline.\n\n Try changing clip(s) color " + "or disable clip color restriction." + ) + else: + raise CreatorError( + "No clips found on current timeline." + ) + + self.log.info(f"Selected: {self.selected}") + + # Todo detect audio but no audio track. + # warning # sort selected trackItems by vSync track sorted_selected_track_items = [] @@ -246,6 +313,13 @@ def create(self, subset_name, instance_data, pre_create_data): "Processing track item data: {}".format(track_item_data) ) + instance_data.update({ + "clip_index": index, + "newHierarchyIntegration": True, + # Backwards compatible (Deprecated since 24/06/06) + "newAssetPublishing": True, + }) + # convert track item to timeline media pool item publish_clip = plugin.PublishableClip( track_item_data, @@ -267,20 +341,19 @@ def create(self, subset_name, instance_data, pre_create_data): # TODO: here we need to replicate Traypublisher Editorial workflow # and create shot, plate, review, and audio instances with own # dedicated plugin - - # Create the Publisher instance - instance = CreatedInstance( - product_type=self.product_type, - product_name=publish_clip.product_name, - data=instance_data, - creator=self - ) - instance.transient_data["track_item"] = track_item - self._add_instance_to_context(instance) + for creator_id in ( + "io.ayon.creators.resolve.shot", + "io.ayon.creators.resolve.review", + ): + instance = self.create_context.creators[creator_id].create( + instance_data, None + ) + instance.transient_data["track_item"] = track_item + self._add_instance_to_context(instance) + instances.append(instance) # self.imprint_instance_node(instance_node, # data=instance.data_to_store()) - instances.append(instance) return instances def collect_instances(self): diff --git a/client/ayon_resolve/plugins/publish/collect_current_project.py b/client/ayon_resolve/plugins/publish/collect_current_project.py index dbac3d0635..95e049e688 100644 --- a/client/ayon_resolve/plugins/publish/collect_current_project.py +++ b/client/ayon_resolve/plugins/publish/collect_current_project.py @@ -1,7 +1,6 @@ import pyblish.api -from ayon_core.hosts.resolve import api as rapi -from ayon_core.hosts.resolve.otio import davinci_export +from ayon_resolve import api class CollectResolveProject(pyblish.api.ContextPlugin): @@ -9,22 +8,22 @@ class CollectResolveProject(pyblish.api.ContextPlugin): label = "Collect Project and Current Timeline" order = pyblish.api.CollectorOrder - 0.499 + hosts = ["resolve"] def process(self, context): - resolve_project = rapi.get_current_resolve_project() + resolve_project = api.get_current_resolve_project() timeline = resolve_project.GetCurrentTimeline() - fps = timeline.GetSetting("timelineFrameRate") - - video_tracks = rapi.get_video_track_names() - # adding otio timeline to context - otio_timeline = davinci_export.create_otio_timeline(resolve_project) + video_tracks = api.get_video_track_names() + otio_timeline = api.export_timeline_otio(timeline) + current_file = resolve_project.GetName() + fps = timeline.GetSetting("timelineFrameRate") # update context with main project attributes context.data.update({ # project "activeProject": resolve_project, - "currentFile": resolve_project.GetName(), + "currentFile": current_file, # timeline "otioTimeline": otio_timeline, "videoTracks": video_tracks, diff --git a/client/ayon_resolve/plugins/publish/precollect_instances.py b/client/ayon_resolve/plugins/publish/precollect_instances.py index d37d2e2eee..bd1e994fd3 100644 --- a/client/ayon_resolve/plugins/publish/precollect_instances.py +++ b/client/ayon_resolve/plugins/publish/precollect_instances.py @@ -13,6 +13,8 @@ get_otio_clip_instance_data, ) +import disable_this_one + class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py new file mode 100644 index 0000000000..1d3b7c564b --- /dev/null +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -0,0 +1,69 @@ +import pyblish + + +class PrecollectShot(pyblish.api.InstancePlugin): + """PreCollect new shots.""" + + order = pyblish.api.CollectorOrder - 0.48 + label = "Precollect Clips" + hosts = ["resolve"] + families = ["shot"] + + @staticmethod + def _prepare_context_hierarchy(instance): + """ + TODO: explain + resolve: + https://github.com/ynput/ayon-core/blob/6a07de6eb904c139f6d346fd6f2a7d5042274c71/client/ayon_core/plugins/publish/collect_hierarchy.py#L65 + + traypublisher: + https://github.com/ynput/ayon-traypublisher/blob/develop/client/ayon_traypublisher/plugins/publish/collect_shot_instances.py#L188 + """ + instance.data["folderPath"] = instance.data.pop("hierarchy_path") + instance.data["integrate"] = False # no representation for shot + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + self._prepare_context_hierarchy(instance) + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + instance.data.update( + { + "fps": instance.context.data["fps"], + "resolutionWidth": otio_timeline.metadata["width"], + "resolutionHeight": otio_timeline.metadata["height"], + "pixelAspect": otio_timeline.metadata["pixelAspect"] + } + ) + + try: # opentimelineio >= 0.16.0 + all_clips = otio_timeline.find_clips() + except AttributeError: # legacy + all_clips = otio_timeline.each_clips() + + # Retrieve otioClip from parent context otioTimeline + # See collect_current_project + for otio_clip in all_clips: + for marker in otio_clip.markers: + if ( + marker.metadata.get("clip_index") == + instance.data["clip_index"] + ): + instance.data["otioClip"] = otio_clip + + # Overwrite settings with clip metadata is "sourceResolution" + if marker.metadata["sourceResolution"]: + clip_metadata = otio_clip.media_reference.metadata + instance.data.update({ + "resolutionWidth": clip_metadata["width"], + "resolutionHeight": clip_metadata["height"], + "pixelAspect": clip_metadata["pixelAspect"] + }) + + return + + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) From ba1020205cc8f54bfbc74da4692f5fcb77493d44 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 27 Aug 2024 13:55:24 -0400 Subject: [PATCH 10/58] Get plate, review and audio publishing through. --- client/ayon_resolve/otio/utils.py | 25 +++ .../plugins/create/create_shot_clip.py | 27 ++- .../plugins/publish/precollect_audio.py | 35 ++++ .../plugins/publish/precollect_instances.py | 188 ------------------ .../plugins/publish/precollect_plates.py | 20 ++ .../plugins/publish/precollect_review.py | 40 ++++ .../plugins/publish/precollect_shots.py | 47 ++--- 7 files changed, 159 insertions(+), 223 deletions(-) create mode 100644 client/ayon_resolve/plugins/publish/precollect_audio.py delete mode 100644 client/ayon_resolve/plugins/publish/precollect_instances.py create mode 100644 client/ayon_resolve/plugins/publish/precollect_plates.py create mode 100644 client/ayon_resolve/plugins/publish/precollect_review.py diff --git a/client/ayon_resolve/otio/utils.py b/client/ayon_resolve/otio/utils.py index c03305ff23..5ff35498f1 100644 --- a/client/ayon_resolve/otio/utils.py +++ b/client/ayon_resolve/otio/utils.py @@ -68,3 +68,28 @@ def get_padding_from_path(path): return len(re.findall(padding_pattern, path).pop()) return None + + +def get_marker_from_clip_index(otio_timeline, clip_index): + """ + Args: + otio_timeline (otio.Timeline): The otio timeline to inspect + clip_index (int): The clip index metadata to retrieve. + + Returns: + tuple(otio.Clip, otio.Marker): The associated clip and marker + or (None, None) + """ + try: # opentimelineio >= 0.16.0 + all_clips = otio_timeline.find_clips() + except AttributeError: # legacy + all_clips = otio_timeline.each_clips() + + # Retrieve otioClip from parent context otioTimeline + # See collect_current_project + for otio_clip in all_clips: + for marker in otio_clip.markers: + if (marker.metadata.get("clip_index") == clip_index): + return otio_clip, marker + + return None, None diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index e8c429b0c5..b836baa34c 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -30,7 +30,7 @@ def create(self, instance_data, _): f'{instance_data["hierarchyData"]["shot"]}' ) instance_data.update({ - "productName": "shotMain", + "productName": f"{self.product_type}Main", "label": f"{hierarchy_path} {self.product_type}", "productType": self.product_type, "hierarchy_path": hierarchy_path, @@ -45,23 +45,33 @@ def create(self, instance_data, _): class ResolveShotInstanceCreator(_ResolveInstanceCreator): - """Shot product. - """ + """Shot product type creator class""" identifier = "io.ayon.creators.resolve.shot" product_type = "shot" label = "Editorial Shot" class EditorialReviewInstanceCreator(_ResolveInstanceCreator): - """Review product type class - - Review representation instance. - """ + """Review product type creator class""" identifier = "io.ayon.creators.resolve.review" product_type = "review" label = "Editorial Review" +class EditorialPlateInstanceCreator(_ResolveInstanceCreator): + """Plate product type creator class""" + identifier = "io.ayon.creators.resolve.plate" + product_type = "plate" + label = "Editorial Plate" + + +class EditorialAudioInstanceCreator(_ResolveInstanceCreator): + """Audio product type creator class""" + identifier = "io.ayon.creators.resolve.audio" + product_type = "audio" + label = "Editorial Audio" + + class CreateShotClip(plugin.ResolveCreator): """Publishable clip""" @@ -341,9 +351,12 @@ def create(self, subset_name, instance_data, pre_create_data): # TODO: here we need to replicate Traypublisher Editorial workflow # and create shot, plate, review, and audio instances with own # dedicated plugin + # TODO: should that be choosable for the user ? for creator_id in ( "io.ayon.creators.resolve.shot", "io.ayon.creators.resolve.review", + "io.ayon.creators.resolve.plate", + "io.ayon.creators.resolve.audio", ): instance = self.create_context.creators[creator_id].create( instance_data, None diff --git a/client/ayon_resolve/plugins/publish/precollect_audio.py b/client/ayon_resolve/plugins/publish/precollect_audio.py new file mode 100644 index 0000000000..e5288cb4c6 --- /dev/null +++ b/client/ayon_resolve/plugins/publish/precollect_audio.py @@ -0,0 +1,35 @@ +import pyblish + +from ayon_resolve.otio import utils + + +class PrecollectAudio(pyblish.api.InstancePlugin): + """PreCollect new audio.""" + + order = pyblish.api.CollectorOrder - 0.48 + label = "Precollect Audio" + hosts = ["resolve"] + families = ["audio"] + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + instance.data["folderPath"] = instance.data.pop("hierarchy_path") + + otio_timeline = instance.context.data["otioTimeline"] + otio_clip, marker = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + clip_src = otio_clip.source_range + clip_src_in = clip_src.start_time.to_frames() + clip_src_out = clip_src_in + clip_src.duration.to_frames() + instance.data.update({ + "fps": instance.context.data["fps"], + "clipInH": clip_src_in, + "clipOutH": clip_src_out, + }) diff --git a/client/ayon_resolve/plugins/publish/precollect_instances.py b/client/ayon_resolve/plugins/publish/precollect_instances.py deleted file mode 100644 index bd1e994fd3..0000000000 --- a/client/ayon_resolve/plugins/publish/precollect_instances.py +++ /dev/null @@ -1,188 +0,0 @@ -from pprint import pformat - -import pyblish - -from ayon_core.pipeline import AYON_INSTANCE_ID, AVALON_INSTANCE_ID -from ayon_resolve.api.constants import ( - publish_clip_color -) -from ayon_resolve.api.lib import ( - get_current_timeline_items, - get_timeline_item_ayon_tag, - get_publish_attribute, - get_otio_clip_instance_data, -) - -import disable_this_one - - -class PrecollectInstances(pyblish.api.ContextPlugin): - """Collect all Track items selection.""" - - order = pyblish.api.CollectorOrder - 0.49 - label = "Precollect Instances" - hosts = ["resolve"] - - def process(self, context): - otio_timeline = context.data["otioTimeline"] - selected_timeline_items = get_current_timeline_items( - filter=True, selecting_color=publish_clip_color) - - self.log.info( - "Processing enabled track items: {}".format( - len(selected_timeline_items))) - - for timeline_item_data in selected_timeline_items: - - data = {} - timeline_item = timeline_item_data["clip"]["item"] - - # get ayon tag data - tag_data = get_timeline_item_ayon_tag(timeline_item) - self.log.debug(f"__ tag_data: {pformat(tag_data)}") - - if not tag_data: - continue - - if tag_data.get("id") not in { - AYON_INSTANCE_ID, AVALON_INSTANCE_ID - }: - continue - - media_pool_item = timeline_item.GetMediaPoolItem() - source_duration = int(media_pool_item.GetClipProperty("Frames")) - - # solve handles length - handle_start = min( - tag_data["handleStart"], int(timeline_item.GetLeftOffset())) - handle_end = min( - tag_data["handleEnd"], int( - source_duration - timeline_item.GetRightOffset())) - - self.log.debug("Handles: <{}, {}>".format(handle_start, handle_end)) - - # add tag data to instance data - data.update({ - k: v for k, v in tag_data.items() - if k not in ("id", "applieswhole", "label") - }) - - folder_path = tag_data["folder_path"] - # Backward compatibility fix of 'entity_type' > 'folder_type' - if "parents" in data: - for parent in data["parents"]: - if "entity_type" in parent: - parent["folder_type"] = parent.pop("entity_type") - - # TODO: remove backward compatibility - product_name = tag_data.get("productName") - if product_name is None: - # backward compatibility: subset -> productName - product_name = tag_data.get("subset") - - # backward compatibility: product_name should not be missing - if not product_name: - self.log.error( - "Product name is not defined for: {}".format(folder_path)) - - # TODO: remove backward compatibility - product_type = tag_data.get("productType") - if product_type is None: - # backward compatibility: family -> productType - product_type = tag_data.get("family") - - # backward compatibility: product_type should not be missing - if not product_type: - self.log.error( - "Product type is not defined for: {}".format(folder_path)) - - data.update({ - "name": "{}_{}".format(folder_path, product_name), - "label": "{} {}".format(folder_path, product_name), - "folderPath": folder_path, - "item": timeline_item, - "publish": get_publish_attribute(timeline_item), - "fps": context.data["fps"], - "handleStart": handle_start, - "handleEnd": handle_end, - "newHierarchyIntegration": True, - # Backwards compatible (Deprecated since 24/06/06) - "newAssetPublishing": True, - "families": ["clip"], - "productType": product_type, - "productName": product_name, - "family": product_type - }) - - # otio clip data - otio_data = get_otio_clip_instance_data( - otio_timeline, timeline_item_data) or {} - data.update(otio_data) - - # add resolution - self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # create shot instance for shot attributes create/update - self.create_shot_instance(context, timeline_item, **data) - - self.log.info("Creating instance: {}".format(instance)) - self.log.debug( - "_ instance.data: {}".format(pformat(instance.data))) - - def get_resolution_to_data(self, data, context): - assert data.get("otioClip"), "Missing `otioClip` data" - - # solve source resolution option - if data.get("sourceResolution", None): - otio_clip_metadata = data[ - "otioClip"].media_reference.metadata - data.update({ - "resolutionWidth": otio_clip_metadata["width"], - "resolutionHeight": otio_clip_metadata["height"], - "pixelAspect": otio_clip_metadata["pixelAspect"] - }) - else: - otio_tl_metadata = context.data["otioTimeline"].metadata - data.update({ - "resolutionWidth": otio_tl_metadata["width"], - "resolutionHeight": otio_tl_metadata["height"], - "pixelAspect": otio_tl_metadata["pixelAspect"] - }) - - def create_shot_instance(self, context, timeline_item, **data): - hero_track = data.get("heroTrack") - hierarchy_data = data.get("hierarchyData") - - if not hero_track: - return - - if not hierarchy_data: - return - - folder_path = data["folderPath"] - product_name = "shotMain" - - # insert family into families - product_type = "shot" - - data.update({ - "name": "{}_{}".format(folder_path, product_name), - "label": "{} {}".format(folder_path, product_name), - "folderPath": folder_path, - "productName": product_name, - "productType": product_type, - "family": product_type, - "families": [product_type], - "publish": get_publish_attribute(timeline_item), - - # Tell the integrator this instance does not publish products - # with versions and representation. This product is solely - # intended to created shot hierarchies via the 'Extract Hierarchy - # to AYON' plug-in in ayon-core. - "integrate": False - }) - - context.create_instance(**data) diff --git a/client/ayon_resolve/plugins/publish/precollect_plates.py b/client/ayon_resolve/plugins/publish/precollect_plates.py new file mode 100644 index 0000000000..d4a6619db7 --- /dev/null +++ b/client/ayon_resolve/plugins/publish/precollect_plates.py @@ -0,0 +1,20 @@ +import pyblish + + +class PrecollectPlate(pyblish.api.InstancePlugin): + """PreCollect new plates.""" + + order = pyblish.api.CollectorOrder - 0.48 + label = "Precollect Plate" + hosts = ["resolve"] + families = ["plate"] + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + # Temporary disable no-representation failure. + # TODO not sure what should happen for the plate. + instance.data["folderPath"] = instance.data.pop("hierarchy_path") + instance.data["integrate"] = False diff --git a/client/ayon_resolve/plugins/publish/precollect_review.py b/client/ayon_resolve/plugins/publish/precollect_review.py new file mode 100644 index 0000000000..f73fee6f5b --- /dev/null +++ b/client/ayon_resolve/plugins/publish/precollect_review.py @@ -0,0 +1,40 @@ +import pyblish + +from ayon_resolve.otio import utils + + +class PrecollectReview(pyblish.api.InstancePlugin): + """PreCollect new reviews.""" + + order = pyblish.api.CollectorOrder - 0.48 + label = "Precollect Review" + hosts = ["resolve"] + families = ["review"] + + def process(self, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + """ + instance.data["folderPath"] = instance.data.pop("hierarchy_path") + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + instance.data["fps"] = instance.context.data["fps"] + + otio_clip, _ = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + # TODO: really not sure about this one. + # review media get create but is registered under the selected folder (not associated shot) + instance.data["otioReviewClips"] = [otio_clip] + instance.data.update({ + "frameStart": instance.data["workfileFrameStart"], + "frameEnd": ( + instance.data["workfileFrameStart"] + + otio_clip.duration().to_frames() + ), + }) diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py index 1d3b7c564b..353ea4fb1d 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -1,11 +1,13 @@ import pyblish +from ayon_resolve.otio import utils + class PrecollectShot(pyblish.api.InstancePlugin): """PreCollect new shots.""" order = pyblish.api.CollectorOrder - 0.48 - label = "Precollect Clips" + label = "Precollect Shots" hosts = ["resolve"] families = ["shot"] @@ -40,30 +42,19 @@ def process(self, instance): } ) - try: # opentimelineio >= 0.16.0 - all_clips = otio_timeline.find_clips() - except AttributeError: # legacy - all_clips = otio_timeline.each_clips() - - # Retrieve otioClip from parent context otioTimeline - # See collect_current_project - for otio_clip in all_clips: - for marker in otio_clip.markers: - if ( - marker.metadata.get("clip_index") == - instance.data["clip_index"] - ): - instance.data["otioClip"] = otio_clip - - # Overwrite settings with clip metadata is "sourceResolution" - if marker.metadata["sourceResolution"]: - clip_metadata = otio_clip.media_reference.metadata - instance.data.update({ - "resolutionWidth": clip_metadata["width"], - "resolutionHeight": clip_metadata["height"], - "pixelAspect": clip_metadata["pixelAspect"] - }) - - return - - raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + otio_clip, marker = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + instance.data["otioClip"] = otio_clip + + # Overwrite settings with clip metadata is "sourceResolution" + if marker.metadata["sourceResolution"]: + clip_metadata = otio_clip.media_reference.metadata + instance.data.update({ + "resolutionWidth": clip_metadata["width"], + "resolutionHeight": clip_metadata["height"], + "pixelAspect": clip_metadata["pixelAspect"] + }) From f50dd1b9962a6ca2641a4290a2b007c61ba9543a Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 28 Aug 2024 11:16:49 -0400 Subject: [PATCH 11/58] Handle serialization in markers for all creators. --- client/ayon_resolve/api/plugin.py | 18 +- .../plugins/create/create_shot_clip.py | 302 ++++++++++-------- .../plugins/create/create_workfile.py | 79 ++++- .../plugins/publish/precollect_shots.py | 5 +- 4 files changed, 247 insertions(+), 157 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index c825b6035b..1ba7000991 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -467,9 +467,6 @@ def convert(self): "track_data": self.timeline_item_data["track"] }) - # create openpype tag on timeline_item and add data - lib.imprint(self.timeline_item, self.tag_data) - return self.timeline_item def _populate_timeline_item_default_data(self): @@ -678,15 +675,10 @@ class HiddenResolvePublishCreator(HiddenCreator): settings_category = "resolve" def collect_instances(self): - instances_by_identifier = cache_and_get_instances( - self, SHARED_DATA_KEY, list_instances - ) - for instance_data in instances_by_identifier[self.identifier]: - instance = CreatedInstance.from_existing(instance_data, self) - self._add_instance_to_context(instance) + pass def update_instances(self, update_list): - update_instances(update_list) + pass def remove_instances(self, instances): remove_instances(instances) @@ -723,12 +715,10 @@ def collect_instances(self): self._add_instance_to_context(instance) def update_instances(self, update_list): - update_instances(update_list) + pass def remove_instances(self, instances): - remove_instances(instances) - for instance in instances: - self._remove_instance_from_context(instance) + pass def _store_new_instance(self, new_instance): """Resolve publisher specific method to store instance. diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index b836baa34c..308035158f 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -3,13 +3,16 @@ from ayon_resolve.api import plugin, lib, constants from ayon_resolve.api.lib import ( get_video_track_names, + get_current_timeline_items, create_bin, ) from ayon_core.pipeline.create import CreatorError, CreatedInstance from ayon_core.lib import BoolDef, EnumDef, TextDef, UILabelDef, NumberDef - +# Used as a key by the creators in order to +# retrieve the instances data into clip markers. +_CONTENT_ID = "resolve_sub_products" class _ResolveInstanceCreator(plugin.HiddenResolvePublishCreator): @@ -25,16 +28,20 @@ def create(self, instance_data, _): Return: CreatedInstance: The created instance object for the new shot. """ + instance_data = copy.deepcopy(instance_data) hierarchy_path = ( f'/{instance_data["hierarchy"]}/' f'{instance_data["hierarchyData"]["shot"]}' ) instance_data.update({ - "productName": f"{self.product_type}Main", + "productName": f"{self.product_type}{instance_data['variant']}", "label": f"{hierarchy_path} {self.product_type}", "productType": self.product_type, "hierarchy_path": hierarchy_path, - "shotName": instance_data["hierarchyData"]["shot"] + "shotName": instance_data["hierarchyData"]["shot"], + "newHierarchyIntegration": True, + # Backwards compatible (Deprecated since 24/06/06) + "newAssetPublishing": True, }) new_instance = CreatedInstance( @@ -43,6 +50,43 @@ def create(self, instance_data, _): self._store_new_instance(new_instance) return new_instance + def update_instances(self, update_list): + """Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and it's changes. + """ + for created_inst, _changes in update_list: + track_item = created_inst.transient_data["track_item"] + tag_data = lib.get_timeline_item_ayon_tag(track_item) + instances_data = tag_data[_CONTENT_ID] + + instances_data[self.identifier] = created_inst.data_to_store() + lib.imprint(track_item, tag_data) + + def remove_instances(self, instances): + """Remove instance marker from track item. + + Args: + instance(List[CreatedInstance]): Instance objects which should be + removed. + """ + for instance in instances: + track_item = instance.transient_data["track_item"] + tag_data = lib.get_timeline_item_ayon_tag(track_item) + instances_data = tag_data.get(_CONTENT_ID, {}) + _ = instances_data.pop(self.identifier, None) + self._remove_instance_from_context(instance) + + # Remove markers if deleted all of the instances + if not instances_data: + track_item.DeleteMarkersByColor(constants.ayon_marker_color) + track_item.ClearClipColor() + # Push edited data in marker + else: + lib.imprint(track_item, tag_data) + class ResolveShotInstanceCreator(_ResolveInstanceCreator): """Shot product type creator class""" @@ -115,42 +159,22 @@ def header_label(text): ), default=True), - # renameHierarchy + # export outputs UILabelDef( - label=header_label("Shot Hierarchy And Rename Settings") - ), - TextDef( - "hierarchy", - label="Shot Parent Hierarchy", - tooltip="Parents folder for shot root folder, " - "Template filled with *Hierarchy Data* section", - default=presets.get("hierarchy", "{folder}/{sequence}"), - ), - BoolDef( - "clipRename", - label="Rename clips", - tooltip="Renaming selected clips on fly", - default=presets.get("clipRename", False), - ), - TextDef( - "clipName", - label="Clip Name Template", - tooltip="template for creating shot names, used for " - "renaming (use rename: on)", - default=presets.get("clipName", "{sequence}{shot}"), - ), - NumberDef( - "countFrom", - label="Count sequence from", - tooltip="Set where the sequence number starts from", - default=presets.get("countFrom", 10), - ), - NumberDef( - "countSteps", - label="Stepping number", - tooltip="What number is adding every new step", - default=presets.get("countSteps", 10), + label=header_label("Additional Export(s)") ), + BoolDef("export_plate", + label="Plate", + tooltip="Export Plate output(s)", + default=False), + BoolDef("export_review", + label="Review", + tooltip="Export Review output(s)", + default=False), + BoolDef("export_audio", + label="Audio", + tooltip="Export Audio output(s)", + default=False), # hierarchyData UILabelDef( @@ -189,9 +213,46 @@ def header_label(text): default=presets.get("shot", "sh###"), ), + # renameHierarchy + UILabelDef( + label=header_label("Shot Hierarchy and Rename Settings") + ), + TextDef( + "hierarchy", + label="Shot Parent Hierarchy", + tooltip="Parents folder for shot root folder, " + "Template filled with *Hierarchy Data* section", + default=presets.get("hierarchy", "{folder}/{sequence}"), + ), + BoolDef( + "clipRename", + label="Rename Clips", + tooltip="Renaming selected clips on fly", + default=presets.get("clipRename", False), + ), + TextDef( + "clipName", + label="Clip Name Template", + tooltip="template for creating shot names, used for " + "renaming (use rename: on)", + default=presets.get("clipName", "{sequence}{shot}"), + ), + NumberDef( + "countFrom", + label="Count Sequence from", + tooltip="Set where the sequence number starts from", + default=presets.get("countFrom", 10), + ), + NumberDef( + "countSteps", + label="Stepping Number", + tooltip="What number is adding every new step", + default=presets.get("countSteps", 10), + ), + # verticalSync UILabelDef( - label=header_label("Vertical Synchronization Of Attributes") + label=header_label("Vertical Synchronization of Attributes") ), BoolDef( "vSyncOn", @@ -202,30 +263,11 @@ def header_label(text): ), EnumDef( "vSyncTrack", - label="Hero track", + label="Hero Track", tooltip="Select driving track name which should " "be mastering all others", items=gui_tracks or [""], ), - - # publishSettings - UILabelDef( - label=header_label("Publish Settings") - ), - EnumDef( - "variant", - label="Product Variant", - tooltip="Chose variant which will be then used for " - "product name, if " - "is selected, name of track layer will be used", - items=['', 'main', 'bg', 'fg', 'bg', 'animatic'], - ), - EnumDef( - "productType", - label="Product Type", - tooltip="How the product will be used", - items=['plate'], # it is prepared for more types - ), EnumDef( "reviewTrack", label="Use Review Track", @@ -233,18 +275,6 @@ def header_label(text): "'< none >' is defined nothing will be generated.", items=['< none >'] + gui_tracks, ), - BoolDef( - "audio", - label="Include audio", - tooltip="Process subsets with corresponding audio", - default=False, - ), - BoolDef( - "sourceResolution", - label="Source resolution", - tooltip="Is resoloution taken from timeline or source?", - default=False, - ), # shotAttr UILabelDef( @@ -258,16 +288,22 @@ def header_label(text): ), NumberDef( "handleStart", - label="Handle start (head)", + label="Handle Start (head)", tooltip="Handle at start of clip", default=presets.get("handleStart", 0), ), NumberDef( "handleEnd", - label="Handle end (tail)", + label="Handle End (tail)", tooltip="Handle at end of clip", default=presets.get("handleEnd", 0), ), + BoolDef( + "sourceResolution", + label="Use Source Resolution", + tooltip="Is resoloution/pixel aspect taken from timeline or source?", + default=False, + ), ] presets = None @@ -296,11 +332,14 @@ def create(self, subset_name, instance_data, pre_create_data): raise CreatorError( "No clips found on current timeline." ) - self.log.info(f"Selected: {self.selected}") - # Todo detect audio but no audio track. - # warning + audio_clips = get_current_timeline_items(track_type="audio") + if not audio_clips and pre_create_data.get("export_audio"): + raise CreatorError( + "You must have audio in your active " + "timeline in order to export audio." + ) # sort selected trackItems by vSync track sorted_selected_track_items = [] @@ -317,65 +356,80 @@ def create(self, subset_name, instance_data, pre_create_data): # create media bin for compound clips (trackItems) media_pool_folder = create_bin(self.timeline.GetName()) + # detecte enabled creators for review, plate and audio + all_creators = { + "io.ayon.creators.resolve.shot": True, + "io.ayon.creators.resolve.review": pre_create_data.get("export_review", False), + "io.ayon.creators.resolve.plate": pre_create_data.get("export_plate", False), + "io.ayon.creators.resolve.audio": pre_create_data.get("export_audio", False), + } + enabled_creators = tuple(cre for cre, enabled in all_creators.items() if enabled) + instances = [] for index, track_item_data in enumerate(sorted_selected_track_items): + + clip_instances = {} + instance_data["clip_index"] = index self.log.info( - "Processing track item data: {}".format(track_item_data) + "Processing track item data: {} (index: {})".format( + track_item_data, index) ) - instance_data.update({ - "clip_index": index, - "newHierarchyIntegration": True, - # Backwards compatible (Deprecated since 24/06/06) - "newAssetPublishing": True, - }) - # convert track item to timeline media pool item publish_clip = plugin.PublishableClip( track_item_data, pre_create_data, media_pool_folder, rename_index=index, - data=instance_data + data=instance_data # insert additional data in instance_data ) - track_item = publish_clip.convert() if track_item is None: # Ignore input clips that do not convert into a track item # from `PublishableClip.convert` continue - track_item.SetClipColor(constants.publish_clip_color) - - instance_data = copy.deepcopy(instance_data) - # TODO: here we need to replicate Traypublisher Editorial workflow - # and create shot, plate, review, and audio instances with own - # dedicated plugin - # TODO: should that be choosable for the user ? - for creator_id in ( - "io.ayon.creators.resolve.shot", - "io.ayon.creators.resolve.review", - "io.ayon.creators.resolve.plate", - "io.ayon.creators.resolve.audio", - ): + # Delete any existing instances previously generated for the clip. + prev_tag_data = lib.get_timeline_item_ayon_tag(track_item) + if prev_tag_data: + for creator_id, inst_data in prev_tag_data[_CONTENT_ID].items(): + creator = self.create_context.creators[creator_id] + prev_instances = [ + inst for inst_id, inst + in self.create_context.instances_by_id.items() + if inst_id == inst_data["instance_id"] + ] + creator.remove_instances(prev_instances) + + # Create new product(s) instances. + for creator_id in enabled_creators: instance = self.create_context.creators[creator_id].create( instance_data, None ) instance.transient_data["track_item"] = track_item self._add_instance_to_context(instance) - instances.append(instance) + clip_instances[creator_id] = instance.data_to_store() + + # insert clip index and created instances + # data as track_item metadata, to retrieve those + # during collections and publishing phases + lib.imprint( + track_item, + data={ + _CONTENT_ID: clip_instances, + "clip_index": index, + }, + ) + track_item.SetClipColor(constants.publish_clip_color) + instances.extend(list(clip_instances.values())) - # self.imprint_instance_node(instance_node, - # data=instance.data_to_store()) return instances def collect_instances(self): """Collect all created instances from current timeline.""" - selected_timeline_items = lib.get_current_timeline_items( - filter=True, selecting_color=constants.publish_clip_color) - + all_timeline_items = lib.get_current_timeline_items() instances = [] - for timeline_item_data in selected_timeline_items: + for timeline_item_data in all_timeline_items: timeline_item = timeline_item_data["clip"]["item"] # get openpype tag data @@ -383,38 +437,18 @@ def collect_instances(self): if not tag_data: continue - instance = CreatedInstance.from_existing(tag_data, self) - instance.transient_data["track_item"] = timeline_item - self._add_instance_to_context(instance) + for creator_id, data in tag_data.get(_CONTENT_ID, {}).items(): + creator = self.create_context.creators[creator_id] + instance = CreatedInstance.from_existing(data, creator) + instance.transient_data["track_item"] = timeline_item + self._add_instance_to_context(instance) + instances.append(instance) return instances def update_instances(self, update_list): - """Store changes of existing instances so they can be recollected. - - Args: - update_list(List[UpdateData]): Gets list of tuples. Each item - contain changed instance and it's changes. - """ - for created_inst, _changes in update_list: - track_item = created_inst.transient_data["track_item"] - data = created_inst.data_to_store() - self.log.info(f"Storing data: {data}") - - lib.imprint(track_item, data) + """Never called, update is handled via _ResolveInstanceCreator.""" def remove_instances(self, instances): - """Remove instance marker from track item. - - Args: - instance(List[CreatedInstance]): Instance objects which should be - removed. - """ - for instance in instances: - track_item = instance.transient_data["track_item"] - - # removing instance by marker color - print(f"Removing instance: {track_item.GetName()}") - track_item.DeleteMarkersByColor(constants.ayon_marker_color) - - self._remove_instance_from_context(instance) + """Never called, removal is handled via _ResolveInstanceCreator.""" + diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 2a8183da7b..308ca92f7c 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +import json + import ayon_api from ayon_core.pipeline import ( AutoCreator, CreatedInstance, ) +from ayon_resolve.api import lib +from ayon_resolve.api import constants + class CreateWorkfile(AutoCreator): """Workfile auto-creator.""" @@ -17,8 +22,45 @@ class CreateWorkfile(AutoCreator): default_variant = "Main" - def collect_instances(self): + def _dumps_data_as_marker(self, data): + """Store workfile as timeline marker. + + Args: + data (dict): The data to store on the timeline. + """ + timeline = lib.get_current_timeline() + note = json.dumps(data) + + timeline.AddMarker( + timeline.GetStartFrame(), + constants.ayon_marker_color, + constants.ayon_marker_name, + note, + constants.ayon_marker_duration + ) + + def _get_timeline_marker(self): + """Retrieve workfile marker from timeline.""" + timeline = lib.get_current_timeline() + for idx, marker_info in timeline.GetMarkers().items(): + if ( + marker_info["name"] == constants.ayon_marker_name + and marker_info["color"] == constants.ayon_marker_color + ): + return idx, marker_info + + return None, None + + def _loads_data_from_marker(self): + """Retrieve workfile from timeline marker.""" + _, marker_info = self._get_timeline_marker() + if not marker_info: + return {} + return json.loads(marker_info["note"]) + + def _create_new_instance(self): + """Create new instance.""" variant = self.default_variant project_name = self.create_context.get_current_project_name() folder_path = self.create_context.get_current_folder_path() @@ -41,6 +83,7 @@ def collect_instances(self): "folderPath": folder_path, "task": task_name, "variant": variant, + "productName": product_name, } data.update( self.get_dynamic_data( @@ -52,9 +95,19 @@ def collect_instances(self): False, ) ) - self.log.info("Auto-creating workfile instance...") + + self._dumps_data_as_marker(data) + return data + + def collect_instances(self): + """Collect from timeline marker or create a new one.""" + data = self._loads_data_from_marker() + if not data: + self.log.info("Auto-creating workfile instance...") + data = self._create_new_instance() + current_instance = CreatedInstance( - self.product_type, product_name, data, self) + self.product_type, data["productName"], data, self) self._add_instance_to_context(current_instance) def create(self, options=None): @@ -63,8 +116,18 @@ def create(self, options=None): pass def update_instances(self, update_list): - # TODO: Implement - # This needs to be implemented to allow persisting any instance - # data on resets. We'll need to decide where to store workfile - # instance data reliably. Likely metadata on the *current project*? - pass + """Store changes in project metadata so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and it's changes. + """ + timeline = lib.get_current_timeline() + frame_id, _ = self._get_timeline_marker() + + if frame_id is not None: + timeline.DeleteMarkerAtFrame(frame_id) + + for created_inst, _changes in update_list: + data = created_inst.data_to_store() + self._dumps_data_as_marker(data) diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py index 353ea4fb1d..e84687ca36 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -51,7 +51,10 @@ def process(self, instance): instance.data["otioClip"] = otio_clip # Overwrite settings with clip metadata is "sourceResolution" - if marker.metadata["sourceResolution"]: + creator_id = instance.data["creator_identifier"] + inst_data = marker.metadata["resolve_sub_products"].get(creator_id, {}) + overwrite_clip_metadata = inst_data.get("sourceResolution", False) + if overwrite_clip_metadata: clip_metadata = otio_clip.media_reference.metadata instance.data.update({ "resolutionWidth": clip_metadata["width"], From 92a6813f2fc6ee8e7d81337d9ea50c09685e36bf Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 28 Aug 2024 15:01:26 -0400 Subject: [PATCH 12/58] Ensure both AYONs and Resolve OTIO work properly. --- client/ayon_resolve/api/__init__.py | 2 + client/ayon_resolve/api/constants.py | 1 + client/ayon_resolve/api/lib.py | 29 +++++++++- client/ayon_resolve/otio/utils.py | 27 ++++++++- .../plugins/create/create_shot_clip.py | 29 ++++++---- .../plugins/create/create_workfile.py | 17 ++++++ .../plugins/publish/precollect_audio.py | 2 +- .../plugins/publish/precollect_shots.py | 57 +++++++++++++------ 8 files changed, 132 insertions(+), 32 deletions(-) diff --git a/client/ayon_resolve/api/__init__.py b/client/ayon_resolve/api/__init__.py index b92091d066..8b4e88f2ec 100644 --- a/client/ayon_resolve/api/__init__.py +++ b/client/ayon_resolve/api/__init__.py @@ -28,6 +28,7 @@ create_media_pool_item, create_timeline_item, get_timeline_item, + get_clip_resolution_from_media_pool, get_video_track_names, get_current_timeline_items, get_timeline_item_by_name, @@ -107,6 +108,7 @@ "create_media_pool_item", "create_timeline_item", "get_timeline_item", + "get_clip_resolution_from_media_pool", "get_video_track_names", "get_current_timeline_items", "get_timeline_item_by_name", diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py index 4b809e8786..5bfdcdb986 100644 --- a/client/ayon_resolve/api/constants.py +++ b/client/ayon_resolve/api/constants.py @@ -2,6 +2,7 @@ rename_index = 0 rename_add = 0 +selected_clip_color = "Chocolate" publish_clip_color = "Pink" ayon_marker_workflow = True diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 9c259b5eb6..0404172a13 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -409,7 +409,7 @@ def get_current_timeline_items( selecting_color: str = None) -> List[Dict[str, Any]]: """Get all available current timeline track items""" track_type = track_type or "video" - selecting_color = selecting_color or "Chocolate" + selecting_color = selecting_color or constants.selected_clip_color resolve_project = get_current_resolve_project() # get timeline anyhow @@ -908,6 +908,32 @@ def _convert_resolve_list_type(resolve_list): return [resolve_list[i] for i in sorted(resolve_list.keys())] +def get_clip_resolution_from_media_pool(timeline_item_data): + """Return the clip resolution from media pool data. + + Args: + timeline_item_data (dict): Timeline item to investigate. + + Returns: + resolution_info (dict): The parsed resolution data. + """ + clip_item = timeline_item_data["clip"]["item"] + media_pool_item = clip_item.GetMediaPoolItem() + clip_properties = media_pool_item.GetClipProperty() + + try: + width, height = clip_properties["Resolution"].split("x") + except (KeyError, ValueError): + width = height = None + + try: + pixel_aspect = int(clip_properties["PAR"]) # Pixel Aspect Resolution + except(KeyError, ValueError): + pixel_aspect = 1.0 + + return {"width": width, "height": height, "pixelAspect": pixel_aspect} + + def create_otio_time_range_from_timeline_item_data(timeline_item_data): timeline_item = timeline_item_data["clip"]["item"] resolve_project = timeline_item_data["project"] @@ -1008,7 +1034,6 @@ def export_timeline_otio_to_file(timeline, filepath): """ try: from . import bmdvr - raise AttributeError("TODO investigate export with metadata") timeline.Export(filepath, bmdvr.EXPORT_OTIO) except Exception as error: diff --git a/client/ayon_resolve/otio/utils.py b/client/ayon_resolve/otio/utils.py index 5ff35498f1..647a1541c0 100644 --- a/client/ayon_resolve/otio/utils.py +++ b/client/ayon_resolve/otio/utils.py @@ -1,3 +1,4 @@ +import json import re import opentimelineio as otio @@ -70,6 +71,27 @@ def get_padding_from_path(path): return None +def unwrap_resolve_otio_marker(marker): + """ + Args: + marker (opentimelineio.schema.Marker): The marker to unwrap. + + Returns: + marker (opentimelineio.schema.Marker): Conformed marker. + """ + # Resolve native OTIO exporter messes up the marker + # dict metadata for some reasons. + # {dict_info} -> {"Resolve_OTIO": {"Note": "string_dict_info"}} + try: + marker_note = marker.metadata["Resolve_OTIO"]["Note"] + except KeyError: + return marker + + marker_note_dict = json.loads(marker_note) + marker.metadata.update(marker_note_dict) # prevent additional resolve keys + return marker + + def get_marker_from_clip_index(otio_timeline, clip_index): """ Args: @@ -89,7 +111,8 @@ def get_marker_from_clip_index(otio_timeline, clip_index): # See collect_current_project for otio_clip in all_clips: for marker in otio_clip.markers: - if (marker.metadata.get("clip_index") == clip_index): - return otio_clip, marker + marker = unwrap_resolve_otio_marker(marker) + if marker.metadata.get("clip_index") == clip_index: + return otio_clip, marker return None, None diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 308035158f..6ae34c88e3 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -48,7 +48,7 @@ def create(self, instance_data, _): self.product_type, instance_data["productName"], instance_data, self ) self._store_new_instance(new_instance) - return new_instance + return new_instance def update_instances(self, update_list): """Store changes of existing instances so they can be recollected. @@ -82,7 +82,9 @@ def remove_instances(self, instances): # Remove markers if deleted all of the instances if not instances_data: track_item.DeleteMarkersByColor(constants.ayon_marker_color) - track_item.ClearClipColor() + if track_item.GetClipColor() != constants.selected_clip_color: + track_item.ClearClipColor() + # Push edited data in marker else: lib.imprint(track_item, tag_data) @@ -368,12 +370,13 @@ def create(self, subset_name, instance_data, pre_create_data): instances = [] for index, track_item_data in enumerate(sorted_selected_track_items): - clip_instances = {} - instance_data["clip_index"] = index - self.log.info( - "Processing track item data: {} (index: {})".format( - track_item_data, index) - ) + # Compute and store resolution metadata from mediapool clip. + resolution_data = lib.get_clip_resolution_from_media_pool(track_item_data) + item_unique_id = track_item_data["clip"]["item"].GetUniqueId() + instance_data.update({ + "clip_index": item_unique_id, + "clip_source_resolution": resolution_data, + }) # convert track item to timeline media pool item publish_clip = plugin.PublishableClip( @@ -389,6 +392,11 @@ def create(self, subset_name, instance_data, pre_create_data): # from `PublishableClip.convert` continue + self.log.info( + "Processing track item data: {} (index: {})".format( + track_item_data, index) + ) + # Delete any existing instances previously generated for the clip. prev_tag_data = lib.get_timeline_item_ayon_tag(track_item) if prev_tag_data: @@ -402,6 +410,7 @@ def create(self, subset_name, instance_data, pre_create_data): creator.remove_instances(prev_instances) # Create new product(s) instances. + clip_instances = {} for creator_id in enabled_creators: instance = self.create_context.creators[creator_id].create( instance_data, None @@ -410,14 +419,14 @@ def create(self, subset_name, instance_data, pre_create_data): self._add_instance_to_context(instance) clip_instances[creator_id] = instance.data_to_store() - # insert clip index and created instances + # insert clip unique ID and created instances # data as track_item metadata, to retrieve those # during collections and publishing phases lib.imprint( track_item, data={ _CONTENT_ID: clip_instances, - "clip_index": index, + "clip_index": item_unique_id, }, ) track_item.SetClipColor(constants.publish_clip_color) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 308ca92f7c..0001ff0752 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -28,7 +28,23 @@ def _dumps_data_as_marker(self, data): Args: data (dict): The data to store on the timeline. """ + # Append global project + # (timeline metadata is not maintained by Resolve native OTIO) timeline = lib.get_current_timeline() + timeline_settings = timeline.GetSetting() + + try: + pixel_aspect = int(timeline_settings["timelinePixelAspectRatio"]) + except ValueError: + pixel_aspect = 1.0 + + data.update({ + "width": timeline_settings["timelineResolutionWidth"], + "height": timeline_settings["timelineResolutionHeight"], + "pixelAspect": pixel_aspect + }) + + # Store as marker note data note = json.dumps(data) timeline.AddMarker( @@ -105,6 +121,7 @@ def collect_instances(self): if not data: self.log.info("Auto-creating workfile instance...") data = self._create_new_instance() + self._dumps_data_as_marker(data) current_instance = CreatedInstance( self.product_type, data["productName"], data, self) diff --git a/client/ayon_resolve/plugins/publish/precollect_audio.py b/client/ayon_resolve/plugins/publish/precollect_audio.py index e5288cb4c6..6e8c487c6b 100644 --- a/client/ayon_resolve/plugins/publish/precollect_audio.py +++ b/client/ayon_resolve/plugins/publish/precollect_audio.py @@ -19,7 +19,7 @@ def process(self, instance): instance.data["folderPath"] = instance.data.pop("hierarchy_path") otio_timeline = instance.context.data["otioTimeline"] - otio_clip, marker = utils.get_marker_from_clip_index( + otio_clip, _ = utils.get_marker_from_clip_index( otio_timeline, instance.data["clip_index"] ) if not otio_clip: diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py index e84687ca36..77425e6616 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -33,31 +33,54 @@ def process(self, instance): # Adjust instance data from parent otio timeline. otio_timeline = instance.context.data["otioTimeline"] - instance.data.update( - { - "fps": instance.context.data["fps"], - "resolutionWidth": otio_timeline.metadata["width"], - "resolutionHeight": otio_timeline.metadata["height"], - "pixelAspect": otio_timeline.metadata["pixelAspect"] - } - ) - otio_clip, marker = utils.get_marker_from_clip_index( otio_timeline, instance.data["clip_index"] ) if not otio_clip: raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + # Retrieve AyonData marker for associated clip. instance.data["otioClip"] = otio_clip - - # Overwrite settings with clip metadata is "sourceResolution" creator_id = instance.data["creator_identifier"] inst_data = marker.metadata["resolve_sub_products"].get(creator_id, {}) + + # Overwrite settings with clip metadata is "sourceResolution" overwrite_clip_metadata = inst_data.get("sourceResolution", False) if overwrite_clip_metadata: - clip_metadata = otio_clip.media_reference.metadata - instance.data.update({ - "resolutionWidth": clip_metadata["width"], - "resolutionHeight": clip_metadata["height"], - "pixelAspect": clip_metadata["pixelAspect"] - }) + clip_metadata = inst_data["clip_source_resolution"] + width = clip_metadata["width"] + height = clip_metadata["height"] + pixel_aspect = clip_metadata["pixelAspect"] + + else: + # AYON's OTIO export = resolution from timeline metadata. + # This is metadata is inserted by ayon_resolve.otio.davinci_export. + width = height = None + try: + width = otio_timeline.metadata["width"] + height = otio_timeline.metadata["height"] + pixel_aspect = otio_timeline.metadata["pixelAspect"] + + # Resolve native OTIO export trashes any timeline metadata + # so force re-compute it from track workfile metadata + except KeyError: + # Retrieve AyonData marker for timeline. + for marker in otio_timeline.tracks.markers: + if marker.name == "AyonData": + utils.unwrap_resolve_otio_marker(marker) + width = marker.metadata["width"] + height = marker.metadata["height"] + pixel_aspect = marker.metadata["pixelAspect"] + break + else: + raise RuntimeError("Could not retrieve timeline " + "resolution metdata from otio_timeline.") + + instance.data.update( + { + "fps": instance.context.data["fps"], + "resolutionWidth": width, + "resolutionHeight": height, + "pixelAspect": pixel_aspect, + } + ) From b873076cf30224cfda730c14200d969a59e4d385 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 3 Sep 2024 10:27:02 -0400 Subject: [PATCH 13/58] Address feedback from PR. --- .../plugins/create/create_shot_clip.py | 71 ++++++++++--------- .../plugins/publish/precollect_plates.py | 16 ++++- .../plugins/publish/precollect_review.py | 40 ----------- 3 files changed, 53 insertions(+), 74 deletions(-) delete mode 100644 client/ayon_resolve/plugins/publish/precollect_review.py diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 6ae34c88e3..e1287a9f0f 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -28,6 +28,9 @@ def create(self, instance_data, _): Return: CreatedInstance: The created instance object for the new shot. """ + if instance_data.get("variant") == "": + instance_data["variant"] = instance_data["hierarchyData"]["track"] + instance_data = copy.deepcopy(instance_data) hierarchy_path = ( f'/{instance_data["hierarchy"]}/' @@ -97,13 +100,6 @@ class ResolveShotInstanceCreator(_ResolveInstanceCreator): label = "Editorial Shot" -class EditorialReviewInstanceCreator(_ResolveInstanceCreator): - """Review product type creator class""" - identifier = "io.ayon.creators.resolve.review" - product_type = "review" - label = "Editorial Review" - - class EditorialPlateInstanceCreator(_ResolveInstanceCreator): """Plate product type creator class""" identifier = "io.ayon.creators.resolve.plate" @@ -161,23 +157,6 @@ def header_label(text): ), default=True), - # export outputs - UILabelDef( - label=header_label("Additional Export(s)") - ), - BoolDef("export_plate", - label="Plate", - tooltip="Export Plate output(s)", - default=False), - BoolDef("export_review", - label="Review", - tooltip="Export Review output(s)", - default=False), - BoolDef("export_audio", - label="Audio", - tooltip="Export Audio output(s)", - default=False), - # hierarchyData UILabelDef( label=header_label("Shot Template Keywords") @@ -270,6 +249,25 @@ def header_label(text): "be mastering all others", items=gui_tracks or [""], ), + + # publishSettings + UILabelDef( + label=header_label("Publish Settings") + ), + EnumDef( + "variant", + label="Product Variant", + tooltip="Chose variant which will be then used for " + "product name, if " + "is selected, name of track layer will be used", + items=['', 'main', 'bg', 'fg', 'bg', 'animatic'], + ), + EnumDef( + "productType", + label="Product Type", + tooltip="How the product will be used", + items=['plate'], # it is prepared for more types + ), EnumDef( "reviewTrack", label="Use Review Track", @@ -277,6 +275,18 @@ def header_label(text): "'< none >' is defined nothing will be generated.", items=['< none >'] + gui_tracks, ), + BoolDef( + "export_audio", + label="Include audio", + tooltip="Process subsets with corresponding audio", + default=False, + ), + BoolDef( + "sourceResolution", + label="Source resolution", + tooltip="Is resoloution taken from timeline or source?", + default=False, + ), # shotAttr UILabelDef( @@ -299,13 +309,7 @@ def header_label(text): label="Handle End (tail)", tooltip="Handle at end of clip", default=presets.get("handleEnd", 0), - ), - BoolDef( - "sourceResolution", - label="Use Source Resolution", - tooltip="Is resoloution/pixel aspect taken from timeline or source?", - default=False, - ), + ), ] presets = None @@ -316,6 +320,8 @@ def create(self, subset_name, instance_data, pre_create_data): instance_data, pre_create_data) + instance_data["variant"] = pre_create_data["variant"] + if not self.timeline: raise CreatorError( "You must be in an active timeline to " @@ -361,8 +367,7 @@ def create(self, subset_name, instance_data, pre_create_data): # detecte enabled creators for review, plate and audio all_creators = { "io.ayon.creators.resolve.shot": True, - "io.ayon.creators.resolve.review": pre_create_data.get("export_review", False), - "io.ayon.creators.resolve.plate": pre_create_data.get("export_plate", False), + "io.ayon.creators.resolve.plate": True, "io.ayon.creators.resolve.audio": pre_create_data.get("export_audio", False), } enabled_creators = tuple(cre for cre, enabled in all_creators.items() if enabled) diff --git a/client/ayon_resolve/plugins/publish/precollect_plates.py b/client/ayon_resolve/plugins/publish/precollect_plates.py index d4a6619db7..cb97eeaede 100644 --- a/client/ayon_resolve/plugins/publish/precollect_plates.py +++ b/client/ayon_resolve/plugins/publish/precollect_plates.py @@ -1,5 +1,7 @@ import pyblish +from ayon_resolve.otio import utils + class PrecollectPlate(pyblish.api.InstancePlugin): """PreCollect new plates.""" @@ -17,4 +19,16 @@ def process(self, instance): # Temporary disable no-representation failure. # TODO not sure what should happen for the plate. instance.data["folderPath"] = instance.data.pop("hierarchy_path") - instance.data["integrate"] = False + instance.data["families"].append("clip") + + # Adjust instance data from parent otio timeline. + otio_timeline = instance.context.data["otioTimeline"] + instance.data["fps"] = instance.context.data["fps"] + + otio_clip, _ = utils.get_marker_from_clip_index( + otio_timeline, instance.data["clip_index"] + ) + if not otio_clip: + raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + + instance.data["otioClip"] = otio_clip diff --git a/client/ayon_resolve/plugins/publish/precollect_review.py b/client/ayon_resolve/plugins/publish/precollect_review.py deleted file mode 100644 index f73fee6f5b..0000000000 --- a/client/ayon_resolve/plugins/publish/precollect_review.py +++ /dev/null @@ -1,40 +0,0 @@ -import pyblish - -from ayon_resolve.otio import utils - - -class PrecollectReview(pyblish.api.InstancePlugin): - """PreCollect new reviews.""" - - order = pyblish.api.CollectorOrder - 0.48 - label = "Precollect Review" - hosts = ["resolve"] - families = ["review"] - - def process(self, instance): - """ - Args: - instance (pyblish.Instance): The shot instance to update. - """ - instance.data["folderPath"] = instance.data.pop("hierarchy_path") - - # Adjust instance data from parent otio timeline. - otio_timeline = instance.context.data["otioTimeline"] - instance.data["fps"] = instance.context.data["fps"] - - otio_clip, _ = utils.get_marker_from_clip_index( - otio_timeline, instance.data["clip_index"] - ) - if not otio_clip: - raise RuntimeError("Could not retrieve otioClip for shot %r", instance) - - # TODO: really not sure about this one. - # review media get create but is registered under the selected folder (not associated shot) - instance.data["otioReviewClips"] = [otio_clip] - instance.data.update({ - "frameStart": instance.data["workfileFrameStart"], - "frameEnd": ( - instance.data["workfileFrameStart"] + - otio_clip.duration().to_frames() - ), - }) From e761efe66cdd6fe795387168f0ee3136d549e76c Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 3 Sep 2024 11:46:12 -0400 Subject: [PATCH 14/58] Address feedback from PR. --- client/ayon_resolve/api/plugin.py | 7 ++++ .../plugins/create/create_shot_clip.py | 42 ++++++++++++------- .../plugins/publish/precollect_audio.py | 2 +- .../plugins/publish/precollect_plates.py | 4 +- .../plugins/publish/precollect_shots.py | 2 +- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 1ba7000991..73894b9c40 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -454,6 +454,13 @@ def convert(self): else: self.tag_data["asset"] = self.ti_name + # AYON unique identifier + folder_path = "/{}/{}".format( + self.tag_data["hierarchy"], + self.tag_data["asset"], + ) + self.tag_data["folder_path"] = folder_path + if not constants.ayon_marker_workflow: # create compound clip workflow lib.create_compound_clip( diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index e1287a9f0f..cf989599ff 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -28,20 +28,11 @@ def create(self, instance_data, _): Return: CreatedInstance: The created instance object for the new shot. """ - if instance_data.get("variant") == "": - instance_data["variant"] = instance_data["hierarchyData"]["track"] - instance_data = copy.deepcopy(instance_data) - hierarchy_path = ( - f'/{instance_data["hierarchy"]}/' - f'{instance_data["hierarchyData"]["shot"]}' - ) instance_data.update({ "productName": f"{self.product_type}{instance_data['variant']}", - "label": f"{hierarchy_path} {self.product_type}", + "label": f"{instance_data['folder_path']} {self.product_type}", "productType": self.product_type, - "hierarchy_path": hierarchy_path, - "shotName": instance_data["hierarchyData"]["shot"], "newHierarchyIntegration": True, # Backwards compatible (Deprecated since 24/06/06) "newAssetPublishing": True, @@ -106,8 +97,27 @@ class EditorialPlateInstanceCreator(_ResolveInstanceCreator): product_type = "plate" label = "Editorial Plate" + def create(self, instance_data, _): + """Return a new CreateInstance for new shot from Resolve. + + Args: + instance_data (dict): global data from original instance + + Return: + CreatedInstance: The created instance object for the new shot. + """ + instance_data = copy.deepcopy(instance_data) + + if instance_data.get("clip_variant") == "": + instance_data["variant"] = instance_data["hierarchyData"]["track"] + + else: + instance_data["variant"] = instance_data["clip_variant"] + + return super().create(instance_data, None) + -class EditorialAudioInstanceCreator(_ResolveInstanceCreator): +class EditorialAudioInstanceCreator(EditorialPlateInstanceCreator): """Audio product type creator class""" identifier = "io.ayon.creators.resolve.audio" product_type = "audio" @@ -207,13 +217,13 @@ def header_label(text): ), BoolDef( "clipRename", - label="Rename Clips", + label="Rename Shots/Clips", tooltip="Renaming selected clips on fly", default=presets.get("clipRename", False), ), TextDef( "clipName", - label="Clip Name Template", + label="Rename Template", tooltip="template for creating shot names, used for " "renaming (use rename: on)", default=presets.get("clipName", "{sequence}{shot}"), @@ -252,10 +262,10 @@ def header_label(text): # publishSettings UILabelDef( - label=header_label("Publish Settings") + label=header_label("Clip Publish Settings") ), EnumDef( - "variant", + "clip_variant", label="Product Variant", tooltip="Chose variant which will be then used for " "product name, if " @@ -320,7 +330,7 @@ def create(self, subset_name, instance_data, pre_create_data): instance_data, pre_create_data) - instance_data["variant"] = pre_create_data["variant"] + instance_data["clip_variant"] = pre_create_data["clip_variant"] if not self.timeline: raise CreatorError( diff --git a/client/ayon_resolve/plugins/publish/precollect_audio.py b/client/ayon_resolve/plugins/publish/precollect_audio.py index 6e8c487c6b..f47a63b8ac 100644 --- a/client/ayon_resolve/plugins/publish/precollect_audio.py +++ b/client/ayon_resolve/plugins/publish/precollect_audio.py @@ -16,7 +16,7 @@ def process(self, instance): Args: instance (pyblish.Instance): The shot instance to update. """ - instance.data["folderPath"] = instance.data.pop("hierarchy_path") + instance.data["folderPath"] = instance.data["folder_path"] otio_timeline = instance.context.data["otioTimeline"] otio_clip, _ = utils.get_marker_from_clip_index( diff --git a/client/ayon_resolve/plugins/publish/precollect_plates.py b/client/ayon_resolve/plugins/publish/precollect_plates.py index cb97eeaede..cbacccd2ba 100644 --- a/client/ayon_resolve/plugins/publish/precollect_plates.py +++ b/client/ayon_resolve/plugins/publish/precollect_plates.py @@ -16,9 +16,7 @@ def process(self, instance): Args: instance (pyblish.Instance): The shot instance to update. """ - # Temporary disable no-representation failure. - # TODO not sure what should happen for the plate. - instance.data["folderPath"] = instance.data.pop("hierarchy_path") + instance.data["folderPath"] = instance.data["folder_path"] instance.data["families"].append("clip") # Adjust instance data from parent otio timeline. diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py index 77425e6616..4637b53093 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -21,7 +21,7 @@ def _prepare_context_hierarchy(instance): traypublisher: https://github.com/ynput/ayon-traypublisher/blob/develop/client/ayon_traypublisher/plugins/publish/collect_shot_instances.py#L188 """ - instance.data["folderPath"] = instance.data.pop("hierarchy_path") + instance.data["folderPath"] = instance.data["folder_path"] instance.data["integrate"] = False # no representation for shot def process(self, instance): From 20b6f1eefbf23c812d3450cd6aa74657192f0701 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 4 Sep 2024 08:20:24 -0400 Subject: [PATCH 15/58] Fix Load Clip/Load Media. --- client/ayon_resolve/api/plugin.py | 16 ++++++++++++++-- client/ayon_resolve/plugins/load/load_media.py | 8 ++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 73894b9c40..38f074b6db 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -1,3 +1,4 @@ +import copy import re import uuid @@ -7,7 +8,8 @@ from ayon_core.pipeline import ( LoaderPlugin, - Creator as NewCreator + Creator as NewCreator, + Anatomy ) from ayon_core.pipeline.create import ( @@ -148,7 +150,7 @@ def load(self, files): # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( - files, + files[0], self.active_bin ) _clip_property = media_pool_item.GetClipProperty @@ -743,3 +745,13 @@ def _store_new_instance(self, new_instance): # Add instance to current context self._add_instance_to_context(new_instance) + + +def get_representation_files(representation): + anatomy = Anatomy() + files = [] + for file_data in representation["files"]: + path = anatomy.fill_root(file_data["path"]) + files.append(path) + return files + diff --git a/client/ayon_resolve/plugins/load/load_media.py b/client/ayon_resolve/plugins/load/load_media.py index c1aaeca6bd..c7f183fa15 100644 --- a/client/ayon_resolve/plugins/load/load_media.py +++ b/client/ayon_resolve/plugins/load/load_media.py @@ -19,7 +19,7 @@ IMAGE_EXTENSIONS ) from ayon_core.lib import BoolDef -from ayon_resolve.api import lib +from ayon_resolve.api import lib, constants from ayon_resolve.api.pipeline import AVALON_CONTAINER_ID @@ -283,7 +283,7 @@ def _import_media_to_bin( "loader": str(self.__class__.__name__), }) - result.SetMetadata(lib.pype_tag_name, json.dumps(data)) + result.SetMetadata(constants.ayon_tag_name, json.dumps(data)) return result @@ -296,7 +296,7 @@ def update(self, container, context): # Get the existing metadata before we update because the # metadata gets removed - data = json.loads(item.GetMetadata(lib.pype_tag_name)) + data = json.loads(item.GetMetadata(constants.ayon_tag_name)) # Get metadata to preserve after the clip replacement # TODO: Maybe preserve more, like LUT, Alpha Mode, Input Sizing Preset @@ -313,7 +313,7 @@ def update(self, container, context): # Update the metadata update_data = self._get_container_data(context) data.update(update_data) - item.SetMetadata(lib.pype_tag_name, json.dumps(data)) + item.SetMetadata(constants.ayon_tag_name, json.dumps(data)) self._set_metadata(media_pool_item=item, context=context) self._set_colorspace_from_representation( From c64571f44e1aa0ec776046c8c3219abab2353a8f Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Wed, 4 Sep 2024 11:18:27 -0400 Subject: [PATCH 16/58] Apply suggestions from code review Co-authored-by: Roy Nieterau --- client/ayon_resolve/plugins/create/create_shot_clip.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index cf989599ff..fe6342d6f0 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -70,7 +70,7 @@ def remove_instances(self, instances): track_item = instance.transient_data["track_item"] tag_data = lib.get_timeline_item_ayon_tag(track_item) instances_data = tag_data.get(_CONTENT_ID, {}) - _ = instances_data.pop(self.identifier, None) + instances_data.pop(self.identifier, None) self._remove_instance_from_context(instance) # Remove markers if deleted all of the instances @@ -343,7 +343,7 @@ def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection", False): raise CreatorError( "No Chocolate-colored clips found from " - "timeline.\n\n Try changing clip(s) color " + "timeline.\n\nTry changing clip(s) color " "or disable clip color restriction." ) else: @@ -374,7 +374,7 @@ def create(self, subset_name, instance_data, pre_create_data): # create media bin for compound clips (trackItems) media_pool_folder = create_bin(self.timeline.GetName()) - # detecte enabled creators for review, plate and audio + # detect enabled creators for review, plate and audio all_creators = { "io.ayon.creators.resolve.shot": True, "io.ayon.creators.resolve.plate": True, From 94260d96e66e6dd1ece39b6339f36917864a1b18 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 4 Sep 2024 15:20:52 -0400 Subject: [PATCH 17/58] Address feedback from PR. --- client/ayon_resolve/api/lib.py | 6 +- client/ayon_resolve/otio/utils.py | 2 +- .../plugins/create/create_shot_clip.py | 2 +- .../plugins/create/create_workfile.py | 73 +++++-------------- .../plugins/publish/precollect_shots.py | 24 +++--- 5 files changed, 34 insertions(+), 73 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 0404172a13..64e0681509 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -967,10 +967,10 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): timeline_range = create_otio_time_range_from_timeline_item_data( timeline_item_data) - try: - all_clips = otio_timeline.each_clip() - except AttributeError: # OpenTimelineIO >= 0.16.0 + try: # opentimelineio >= 0.16.0 all_clips = otio_timeline.find_clips() + except AttributeError: # legacy + all_clips = otio_timeline.each_clip() for otio_clip in all_clips: track_name = otio_clip.parent().name diff --git a/client/ayon_resolve/otio/utils.py b/client/ayon_resolve/otio/utils.py index 647a1541c0..ed00ac2299 100644 --- a/client/ayon_resolve/otio/utils.py +++ b/client/ayon_resolve/otio/utils.py @@ -105,7 +105,7 @@ def get_marker_from_clip_index(otio_timeline, clip_index): try: # opentimelineio >= 0.16.0 all_clips = otio_timeline.find_clips() except AttributeError: # legacy - all_clips = otio_timeline.each_clips() + all_clips = otio_timeline.each_clip() # Retrieve otioClip from parent context otioTimeline # See collect_current_project diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index cf989599ff..bec8fc05cb 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -117,7 +117,7 @@ def create(self, instance_data, _): return super().create(instance_data, None) -class EditorialAudioInstanceCreator(EditorialPlateInstanceCreator): +class EditorialAudioInstanceCreator(_ResolveInstanceCreator): """Audio product type creator class""" identifier = "io.ayon.creators.resolve.audio" product_type = "audio" diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 0001ff0752..66c9e10dbf 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -22,58 +22,29 @@ class CreateWorkfile(AutoCreator): default_variant = "Main" - def _dumps_data_as_marker(self, data): - """Store workfile as timeline marker. + def _dumps_data_as_project_setting(self, data): + """Store workfile as project setting. Args: data (dict): The data to store on the timeline. """ - # Append global project - # (timeline metadata is not maintained by Resolve native OTIO) - timeline = lib.get_current_timeline() - timeline_settings = timeline.GetSetting() - - try: - pixel_aspect = int(timeline_settings["timelinePixelAspectRatio"]) - except ValueError: - pixel_aspect = 1.0 - - data.update({ - "width": timeline_settings["timelineResolutionWidth"], - "height": timeline_settings["timelineResolutionHeight"], - "pixelAspect": pixel_aspect - }) - - # Store as marker note data + # Store info as project setting data. + # Use this hack instead: + # https://forum.blackmagicdesign.com/viewtopic.php?f=21&t= + # 189685&hilit=python+database#p991541 note = json.dumps(data) + proj = lib.get_current_project() + proj.SetSetting("colorVersion10Name", note) - timeline.AddMarker( - timeline.GetStartFrame(), - constants.ayon_marker_color, - constants.ayon_marker_name, - note, - constants.ayon_marker_duration - ) - - def _get_timeline_marker(self): - """Retrieve workfile marker from timeline.""" - timeline = lib.get_current_timeline() - for idx, marker_info in timeline.GetMarkers().items(): - if ( - marker_info["name"] == constants.ayon_marker_name - and marker_info["color"] == constants.ayon_marker_color - ): - return idx, marker_info - - return None, None + def _loads_data_from_project_setting(self): + """Retrieve workfile data from project setting.""" + proj = lib.get_current_project() + setting_content = proj.GetSetting("colorVersion10Name") - def _loads_data_from_marker(self): - """Retrieve workfile from timeline marker.""" - _, marker_info = self._get_timeline_marker() - if not marker_info: - return {} + if setting_content: + return json.loads(setting_content) - return json.loads(marker_info["note"]) + return None def _create_new_instance(self): """Create new instance.""" @@ -112,16 +83,14 @@ def _create_new_instance(self): ) ) - self._dumps_data_as_marker(data) return data def collect_instances(self): """Collect from timeline marker or create a new one.""" - data = self._loads_data_from_marker() + data = self._loads_data_from_project_setting() if not data: self.log.info("Auto-creating workfile instance...") data = self._create_new_instance() - self._dumps_data_as_marker(data) current_instance = CreatedInstance( self.product_type, data["productName"], data, self) @@ -139,12 +108,6 @@ def update_instances(self, update_list): update_list(List[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ - timeline = lib.get_current_timeline() - frame_id, _ = self._get_timeline_marker() - - if frame_id is not None: - timeline.DeleteMarkerAtFrame(frame_id) - - for created_inst, _changes in update_list: + for created_inst, _ in update_list: data = created_inst.data_to_store() - self._dumps_data_as_marker(data) + self._dumps_data_as_project_setting(data) diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/precollect_shots.py index 4637b53093..9bc12257f7 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/precollect_shots.py @@ -1,5 +1,6 @@ import pyblish +from ayon_resolve.api import lib from ayon_resolve.otio import utils @@ -61,20 +62,17 @@ def process(self, instance): height = otio_timeline.metadata["height"] pixel_aspect = otio_timeline.metadata["pixelAspect"] - # Resolve native OTIO export trashes any timeline metadata - # so force re-compute it from track workfile metadata except KeyError: - # Retrieve AyonData marker for timeline. - for marker in otio_timeline.tracks.markers: - if marker.name == "AyonData": - utils.unwrap_resolve_otio_marker(marker) - width = marker.metadata["width"] - height = marker.metadata["height"] - pixel_aspect = marker.metadata["pixelAspect"] - break - else: - raise RuntimeError("Could not retrieve timeline " - "resolution metdata from otio_timeline.") + # Retrieve resolution for project. + project = lib.get_current_project() + project_settings = project.GetSetting() + try: + pixel_aspect = int(project_settings["timelinePixelAspectRatio"]) + except ValueError: + pixel_aspect = 1.0 + + width = int(project_settings["timelineResolutionWidth"]) + height = int(project_settings["timelineResolutionHeight"]) instance.data.update( { From dc1fbdebe095b1f3829f49ccfd29b4b3c0c8a0ed Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 4 Sep 2024 16:06:53 -0400 Subject: [PATCH 18/58] Add detailed_description. --- client/ayon_resolve/plugins/create/create_shot_clip.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index a4152546d8..b2d503e5cb 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -133,10 +133,11 @@ class CreateShotClip(plugin.ResolveCreator): icon = "film" defaults = ["Main"] -# create_allow_context_change = False -# TODO: explain consequence on folderPath -# https://github.com/ynput/ayon-core/blob/6a07de6eb904c139f6d346fd6f2a7d5042274c71/client/ayon_core/tools/publisher/widgets/create_widget.py#L732 - + detailed_description = """ +Publishing clips/plate, audio for new shots to project +or updating already created from Resolve. Publishing will create +OTIO file. +""" create_allow_thumbnail = False def get_pre_create_attr_defs(self): From a9ae6d6cf9b3301de1364b8df07ea0167558071c Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 5 Sep 2024 11:56:36 -0400 Subject: [PATCH 19/58] Address code syntax changes, renaming and minor improvements from PR feedback. --- client/ayon_resolve/api/constants.py | 22 ++++---- client/ayon_resolve/api/lib.py | 50 +++++++++---------- client/ayon_resolve/api/pipeline.py | 2 +- client/ayon_resolve/api/plugin.py | 6 +-- .../plugins/create/create_shot_clip.py | 10 ++-- .../ayon_resolve/plugins/load/load_media.py | 6 +-- .../{precollect_audio.py => collect_audio.py} | 6 +-- .../publish/collect_current_project.py | 6 ++- ...precollect_plates.py => collect_plates.py} | 6 +-- .../{precollect_shots.py => collect_shots.py} | 6 +-- .../plugins/publish/extract_workfile.py | 21 +++----- 11 files changed, 70 insertions(+), 71 deletions(-) rename client/ayon_resolve/plugins/publish/{precollect_audio.py => collect_audio.py} (89%) rename client/ayon_resolve/plugins/publish/{precollect_plates.py => collect_plates.py} (88%) rename client/ayon_resolve/plugins/publish/{precollect_shots.py => collect_shots.py} (96%) diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py index 5bfdcdb986..539f66f1b7 100644 --- a/client/ayon_resolve/api/constants.py +++ b/client/ayon_resolve/api/constants.py @@ -1,19 +1,19 @@ # Ayon sequential rename variables -rename_index = 0 -rename_add = 0 +#rename_index = 0 +#rename_add = 0 -selected_clip_color = "Chocolate" -publish_clip_color = "Pink" -ayon_marker_workflow = True +SELECTED_CLIP_COLOR = "Chocolate" +PUBLISH_CLIP_COLOR = "Pink" +AYON_MARKER_WORKFLOW = True # Ayon compound clip workflow variable -ayon_tag_name = "VFX Notes" +AYON_TAG_NAME = "VFX Notes" # Ayon marker workflow variables -ayon_marker_name = "AyonData" -ayon_marker_duration = 1 -ayon_marker_color = "Mint" -temp_marker_frame = None +AYON_MARKER_NAME = "AyonData" +AYON_MARKER_DURATION = 1 +AYON_MARKER_COLOR = "Mint" +TEMP_MARKER_FRAME = None # Ayon default timeline -ayon_timeline_name = "AyonTimeline" +AYON_TIMELINE_NAME = "AyonTimeline" diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 64e0681509..7072b94963 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -139,7 +139,7 @@ def get_new_timeline(timeline_name: str = None): resolve_project = get_current_resolve_project() media_pool = resolve_project.GetMediaPool() new_timeline = media_pool.CreateEmptyTimeline( - timeline_name or constants.ayon_timeline_name) + timeline_name or constants.AYON_TIMELINE_NAME) resolve_project.SetCurrentTimeline(new_timeline) return new_timeline @@ -409,7 +409,7 @@ def get_current_timeline_items( selecting_color: str = None) -> List[Dict[str, Any]]: """Get all available current timeline track items""" track_type = track_type or "video" - selecting_color = selecting_color or constants.selected_clip_color + selecting_color = selecting_color or constants.SELECTED_CLIP_COLOR resolve_project = get_current_resolve_project() # get timeline anyhow @@ -491,7 +491,7 @@ def get_timeline_item_ayon_tag(timeline_item): """ return_tag = None - if constants.ayon_marker_workflow: + if constants.AYON_MARKER_WORKFLOW: return_tag = get_ayon_marker(timeline_item) else: media_pool_item = timeline_item.GetMediaPoolItem() @@ -502,7 +502,7 @@ def get_timeline_item_ayon_tag(timeline_item): return None for key, data in _tags.items(): # return only correct tag defined by global name - if key in constants.ayon_tag_name: + if key in constants.AYON_TAG_NAME: return_tag = json.loads(data) return return_tag @@ -526,7 +526,7 @@ def set_timeline_item_ayon_tag(timeline_item, data=None): # get available ayon tag if any tag_data = get_timeline_item_ayon_tag(timeline_item) - if constants.ayon_marker_workflow: + if constants.AYON_MARKER_WORKFLOW: # delete tag as it is not updatable if tag_data: delete_ayon_marker(timeline_item) @@ -539,13 +539,13 @@ def set_timeline_item_ayon_tag(timeline_item, data=None): # it not tag then create one tag_data.update(data) media_pool_item.SetMetadata( - constants.ayon_tag_name, json.dumps(tag_data)) + constants.AYON_TAG_NAME, json.dumps(tag_data)) else: tag_data = data # if ayon tag available then update with input data # add it to the input track item timeline_item.SetMetadata( - constants.ayon_tag_name, json.dumps(tag_data)) + constants.AYON_TAG_NAME, json.dumps(tag_data)) return tag_data @@ -608,10 +608,10 @@ def set_ayon_marker(timeline_item, tag_data): # marker attributes frameId = (frame / 10) * 10 - color = constants.ayon_marker_color - name = constants.ayon_marker_name + color = constants.AYON_MARKER_COLOR + name = constants.AYON_MARKER_NAME note = json.dumps(tag_data) - duration = (constants.ayon_marker_duration / 10) * 10 + duration = (constants.AYON_MARKER_DURATION / 10) * 10 timeline_item.AddMarker( frameId, @@ -630,18 +630,18 @@ def get_ayon_marker(timeline_item): name = timeline_item_markers[marker_frame]["name"] print(f"_ marker data: {marker_frame} | {name} | {color} | {note}") if ( - name == constants.ayon_marker_name - and color == constants.ayon_marker_color + name == constants.AYON_MARKER_NAME + and color == constants.AYON_MARKER_COLOR ): - constants.temp_marker_frame = marker_frame + constants.TEMP_MARKER_FRAME = marker_frame return json.loads(note) return {} def delete_ayon_marker(timeline_item): - timeline_item.DeleteMarkerAtFrame(constants.temp_marker_frame) - constants.temp_marker_frame = None + timeline_item.DeleteMarkerAtFrame(constants.TEMP_MARKER_FRAME) + constants.TEMP_MARKER_FRAME = None def create_compound_clip(clip_data, name, folder): @@ -721,9 +721,9 @@ def create_compound_clip(clip_data, name, folder): }]) # Add collected metadata and attributes to the compound clip: - if mp_item.GetMetadata(constants.ayon_tag_name): - clip_attributes[constants.ayon_tag_name] = mp_item.GetMetadata( - constants.ayon_tag_name)[constants.ayon_tag_name] + if mp_item.GetMetadata(constants.AYON_TAG_NAME): + clip_attributes[constants.AYON_TAG_NAME] = mp_item.GetMetadata( + constants.AYON_TAG_NAME)[constants.AYON_TAG_NAME] # stringify clip_attributes = json.dumps(clip_attributes) @@ -733,7 +733,7 @@ def create_compound_clip(clip_data, name, folder): cct.SetMetadata(k, v) # add metadata to cct - cct.SetMetadata(constants.ayon_tag_name, clip_attributes) + cct.SetMetadata(constants.AYON_TAG_NAME, clip_attributes) # reset start timecode of the compound clip cct.SetClipProperty("Start TC", _mp_props("Start TC")) @@ -818,7 +818,7 @@ def get_pype_clip_metadata(clip): mp_item = clip.GetMediaPoolItem() metadata = mp_item.GetMetadata() - return metadata.get(constants.ayon_tag_name) + return metadata.get(constants.AYON_TAG_NAME) def get_clip_attributes(clip): @@ -984,25 +984,23 @@ def get_otio_clip_instance_data(otio_timeline, timeline_item_data): # add pypedata marker to otio_clip metadata for marker in otio_clip.markers: - if constants.ayon_marker_name in marker.name: + if constants.AYON_MARKER_NAME in marker.name: otio_clip.metadata.update(marker.metadata) return {"otioClip": otio_clip} return None -def _get_otio_temp_file(project_name=None, anatomy=None, timeline=None) -> str: +def _get_otio_temp_file(timeline=None) -> str: """Get otio temporary export file. Args: - project_name (str): ayon project name - anatomy (ayon_core.pipeline.Anatomy)[optional]: Anatomy object timeline (resolve.Timeline)[optional]: resolve's object Returns: str: temporary otio filepath """ - project_name = project_name or get_current_project_name() + project_name = get_current_project_name() if timeline is None: resolve_project = get_current_resolve_project() @@ -1013,7 +1011,7 @@ def _get_otio_temp_file(project_name=None, anatomy=None, timeline=None) -> str: timeline_name = timeline.GetName() # get custom staging dir - custom_temp_dir = create_custom_tempdir(project_name, anatomy) + custom_temp_dir = create_custom_tempdir(project_name, None) staging_dir = os.path.normpath( tempfile.mkdtemp(prefix="resolve_otio_tmp_", dir=custom_temp_dir) ) diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index ddff1b0699..2c95db47a6 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -162,7 +162,7 @@ def ls(): # Media Pool instances from Load Media loader for clip in lib.iter_all_media_pool_clips(): - data = clip.GetMetadata(constants.ayon_tag_name) + data = clip.GetMetadata(constants.AYON_TAG_NAME) if not data: continue data = json.loads(data) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 38f074b6db..800f9ce5ce 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -8,7 +8,7 @@ from ayon_core.pipeline import ( LoaderPlugin, - Creator as NewCreator, + Creator, Anatomy ) @@ -302,7 +302,7 @@ def remove(self, container): pass -class ResolveCreator(NewCreator): +class ResolveCreator(Creator): """ Resolve Creator class wrapper""" marker_color = "Purple" @@ -463,7 +463,7 @@ def convert(self): ) self.tag_data["folder_path"] = folder_path - if not constants.ayon_marker_workflow: + if not constants.AYON_MARKER_WORKFLOW: # create compound clip workflow lib.create_compound_clip( self.timeline_item_data, diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index b2d503e5cb..0c31fca616 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -75,8 +75,8 @@ def remove_instances(self, instances): # Remove markers if deleted all of the instances if not instances_data: - track_item.DeleteMarkersByColor(constants.ayon_marker_color) - if track_item.GetClipColor() != constants.selected_clip_color: + track_item.DeleteMarkersByColor(constants.AYON_MARKER_COLOR) + if track_item.GetClipColor() != constants.SELECTED_CLIP_COLOR: track_item.ClearClipColor() # Push edited data in marker @@ -445,7 +445,7 @@ def create(self, subset_name, instance_data, pre_create_data): "clip_index": item_unique_id, }, ) - track_item.SetClipColor(constants.publish_clip_color) + track_item.SetClipColor(constants.PUBLISH_CLIP_COLOR) instances.extend(list(clip_instances.values())) return instances @@ -473,7 +473,9 @@ def collect_instances(self): def update_instances(self, update_list): """Never called, update is handled via _ResolveInstanceCreator.""" + pass def remove_instances(self, instances): """Never called, removal is handled via _ResolveInstanceCreator.""" - + pass + diff --git a/client/ayon_resolve/plugins/load/load_media.py b/client/ayon_resolve/plugins/load/load_media.py index c7f183fa15..b0918f874e 100644 --- a/client/ayon_resolve/plugins/load/load_media.py +++ b/client/ayon_resolve/plugins/load/load_media.py @@ -283,7 +283,7 @@ def _import_media_to_bin( "loader": str(self.__class__.__name__), }) - result.SetMetadata(constants.ayon_tag_name, json.dumps(data)) + result.SetMetadata(constants.AYON_TAG_NAME, json.dumps(data)) return result @@ -296,7 +296,7 @@ def update(self, container, context): # Get the existing metadata before we update because the # metadata gets removed - data = json.loads(item.GetMetadata(constants.ayon_tag_name)) + data = json.loads(item.GetMetadata(constants.AYON_TAG_NAME)) # Get metadata to preserve after the clip replacement # TODO: Maybe preserve more, like LUT, Alpha Mode, Input Sizing Preset @@ -313,7 +313,7 @@ def update(self, container, context): # Update the metadata update_data = self._get_container_data(context) data.update(update_data) - item.SetMetadata(constants.ayon_tag_name, json.dumps(data)) + item.SetMetadata(constants.AYON_TAG_NAME, json.dumps(data)) self._set_metadata(media_pool_item=item, context=context) self._set_colorspace_from_representation( diff --git a/client/ayon_resolve/plugins/publish/precollect_audio.py b/client/ayon_resolve/plugins/publish/collect_audio.py similarity index 89% rename from client/ayon_resolve/plugins/publish/precollect_audio.py rename to client/ayon_resolve/plugins/publish/collect_audio.py index f47a63b8ac..a5fd559725 100644 --- a/client/ayon_resolve/plugins/publish/precollect_audio.py +++ b/client/ayon_resolve/plugins/publish/collect_audio.py @@ -3,11 +3,11 @@ from ayon_resolve.otio import utils -class PrecollectAudio(pyblish.api.InstancePlugin): - """PreCollect new audio.""" +class CollectAudio(pyblish.api.InstancePlugin): + """Collect new audio.""" order = pyblish.api.CollectorOrder - 0.48 - label = "Precollect Audio" + label = "Collect Audio" hosts = ["resolve"] families = ["audio"] diff --git a/client/ayon_resolve/plugins/publish/collect_current_project.py b/client/ayon_resolve/plugins/publish/collect_current_project.py index 95e049e688..46a8da137a 100644 --- a/client/ayon_resolve/plugins/publish/collect_current_project.py +++ b/client/ayon_resolve/plugins/publish/collect_current_project.py @@ -1,5 +1,7 @@ import pyblish.api +from ayon_core.pipeline import registered_host + from ayon_resolve import api @@ -16,7 +18,9 @@ def process(self, context): video_tracks = api.get_video_track_names() otio_timeline = api.export_timeline_otio(timeline) - current_file = resolve_project.GetName() + + host = registered_host() + current_file = host.get_current_workfile() fps = timeline.GetSetting("timelineFrameRate") # update context with main project attributes diff --git a/client/ayon_resolve/plugins/publish/precollect_plates.py b/client/ayon_resolve/plugins/publish/collect_plates.py similarity index 88% rename from client/ayon_resolve/plugins/publish/precollect_plates.py rename to client/ayon_resolve/plugins/publish/collect_plates.py index cbacccd2ba..eaed8e4f79 100644 --- a/client/ayon_resolve/plugins/publish/precollect_plates.py +++ b/client/ayon_resolve/plugins/publish/collect_plates.py @@ -3,11 +3,11 @@ from ayon_resolve.otio import utils -class PrecollectPlate(pyblish.api.InstancePlugin): - """PreCollect new plates.""" +class CollectPlate(pyblish.api.InstancePlugin): + """Collect new plates.""" order = pyblish.api.CollectorOrder - 0.48 - label = "Precollect Plate" + label = "Collect Plate" hosts = ["resolve"] families = ["plate"] diff --git a/client/ayon_resolve/plugins/publish/precollect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py similarity index 96% rename from client/ayon_resolve/plugins/publish/precollect_shots.py rename to client/ayon_resolve/plugins/publish/collect_shots.py index 9bc12257f7..f51c3df5d2 100644 --- a/client/ayon_resolve/plugins/publish/precollect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -4,11 +4,11 @@ from ayon_resolve.otio import utils -class PrecollectShot(pyblish.api.InstancePlugin): - """PreCollect new shots.""" +class CollectShot(pyblish.api.InstancePlugin): + """Collect new shots.""" order = pyblish.api.CollectorOrder - 0.48 - label = "Precollect Shots" + label = "Collect Shots" hosts = ["resolve"] families = ["shot"] diff --git a/client/ayon_resolve/plugins/publish/extract_workfile.py b/client/ayon_resolve/plugins/publish/extract_workfile.py index 1a9477b720..a4b00141a8 100644 --- a/client/ayon_resolve/plugins/publish/extract_workfile.py +++ b/client/ayon_resolve/plugins/publish/extract_workfile.py @@ -2,6 +2,8 @@ import pyblish.api from ayon_core.pipeline import publish +from ayon_core.pipeline import registered_host + from ayon_resolve.api.lib import get_project_manager @@ -16,18 +18,11 @@ class ExtractWorkfile(publish.Extractor): hosts = ["resolve"] def process(self, instance): - # create representation data - if "representations" not in instance.data: - instance.data["representations"] = [] - - name = instance.data["name"] project = instance.context.data["activeProject"] - staging_dir = self.staging_dir(instance) - ext = ".drp" - drp_file_name = name + ext - drp_file_path = os.path.normpath( - os.path.join(staging_dir, drp_file_name)) + host = registered_host() + drp_file_path = host.get_current_workfile() + drp_file_name = os.path.basename(drp_file_path) # write out the drp workfile get_project_manager().ExportProject( @@ -35,10 +30,10 @@ def process(self, instance): # create drp workfile representation representation_drp = { - 'name': ext.lstrip("."), - 'ext': ext.lstrip("."), + 'name': "drp", + 'ext': "drp", 'files': drp_file_name, - "stagingDir": staging_dir, + "stagingDir": os.path.dirname(drp_file_path), } representations = instance.data.setdefault("representations", []) representations.append(representation_drp) From 16d8e64a2344a0af39bb15ae89a4e6f369cc52cc Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 5 Sep 2024 12:32:03 -0400 Subject: [PATCH 20/58] Rework get_representation_files --- client/ayon_resolve/api/plugin.py | 21 ++++++++++++------- client/ayon_resolve/plugins/load/load_clip.py | 10 +++++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 800f9ce5ce..d3f7f04cd4 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -747,11 +747,18 @@ def _store_new_instance(self, new_instance): self._add_instance_to_context(new_instance) -def get_representation_files(representation): - anatomy = Anatomy() - files = [] - for file_data in representation["files"]: - path = anatomy.fill_root(file_data["path"]) - files.append(path) - return files +def get_representation_files(project_name, representation): + """ + Args: + project_name (str): The name of the project. + representation (dict): The representation to inspect. + + Returns: + list. The files associated to the representation. + """ + anatomy = Anatomy(project_name) + return [ + anatomy.fill_root(file_data["path"]) + for file_data in representation["files"] + ] diff --git a/client/ayon_resolve/plugins/load/load_clip.py b/client/ayon_resolve/plugins/load/load_clip.py index bd1847c95a..d360b98f7e 100644 --- a/client/ayon_resolve/plugins/load/load_clip.py +++ b/client/ayon_resolve/plugins/load/load_clip.py @@ -40,7 +40,10 @@ class LoadClip(plugin.TimelineItemLoader): def load(self, context, name, namespace, options): # load clip to timeline and get main variables - files = plugin.get_representation_files(context["representation"]) + files = plugin.get_representation_files( + context["project"]["name"], + context["representation"] + ) timeline_item = plugin.ClipLoader( self, context, **options).load(files) @@ -74,7 +77,10 @@ def update(self, container, context): media_pool_item = timeline_item.GetMediaPoolItem() - files = plugin.get_representation_files(repre_entity) + files = plugin.get_representation_files( + context["project"]["name"], + repre_entity + ) loader = plugin.ClipLoader(self, context) timeline_item = loader.update(timeline_item, files) From 43e37dc98c4195c8603c5effa9ffb0574e60c88c Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 5 Sep 2024 15:24:05 -0400 Subject: [PATCH 21/58] Implement editorialSharedData mechanism. --- client/ayon_resolve/api/constants.py | 3 - client/ayon_resolve/api/plugin.py | 2 +- .../plugins/create/create_shot_clip.py | 39 +++++++++++-- .../plugins/publish/collect_audio.py | 23 ++++---- .../plugins/publish/collect_plates.py | 19 +++---- .../plugins/publish/collect_shots.py | 55 +++++++++++++++---- 6 files changed, 97 insertions(+), 44 deletions(-) diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py index 539f66f1b7..de7d2b6498 100644 --- a/client/ayon_resolve/api/constants.py +++ b/client/ayon_resolve/api/constants.py @@ -1,7 +1,4 @@ # Ayon sequential rename variables -#rename_index = 0 -#rename_add = 0 - SELECTED_CLIP_COLOR = "Chocolate" PUBLISH_CLIP_COLOR = "Pink" AYON_MARKER_WORKFLOW = True diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index d3f7f04cd4..09c649f134 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -461,7 +461,7 @@ def convert(self): self.tag_data["hierarchy"], self.tag_data["asset"], ) - self.tag_data["folder_path"] = folder_path + self.tag_data["target_folder_path"] = folder_path if not constants.AYON_MARKER_WORKFLOW: # create compound clip workflow diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 0c31fca616..91e261832c 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -28,10 +28,12 @@ def create(self, instance_data, _): Return: CreatedInstance: The created instance object for the new shot. """ - instance_data = copy.deepcopy(instance_data) instance_data.update({ "productName": f"{self.product_type}{instance_data['variant']}", - "label": f"{instance_data['folder_path']} {self.product_type}", + "label": ( + f"{instance_data['creator_attributes']['folderPath']} " + f"{self.product_type}" + ), "productType": self.product_type, "newHierarchyIntegration": True, # Backwards compatible (Deprecated since 24/06/06) @@ -106,8 +108,6 @@ def create(self, instance_data, _): Return: CreatedInstance: The created instance object for the new shot. """ - instance_data = copy.deepcopy(instance_data) - if instance_data.get("clip_variant") == "": instance_data["variant"] = instance_data["hierarchyData"]["track"] @@ -427,9 +427,38 @@ def create(self, subset_name, instance_data, pre_create_data): # Create new product(s) instances. clip_instances = {} + shot_creator_id = "io.ayon.creators.resolve.shot" for creator_id in enabled_creators: + sub_instance_data = copy.deepcopy(instance_data) + shot_folder_path = sub_instance_data.pop("target_folder_path") + + # Shot creation + if creator_id == shot_creator_id: + sub_instance_data.update({ + "creator_attributes": { + "folderPath": shot_folder_path, + "workfile_start_frame": \ + sub_instance_data["workfileFrameStart"], + "handle_start": sub_instance_data["handleStart"], + "handle_end": sub_instance_data["handleEnd"] + } + }) + + # Plate, Audio + # insert parent instance data to allow + # metadata recollection as publish time. + else: + parenting_data = clip_instances[shot_creator_id] + sub_instance_data.update({ + "parent_instance_id": parenting_data["instance_id"], + "creator_attributes": { + "folderPath": shot_folder_path, + "parent_instance": parenting_data["label"], + } + }) + instance = self.create_context.creators[creator_id].create( - instance_data, None + sub_instance_data, None ) instance.transient_data["track_item"] = track_item self._add_instance_to_context(instance) diff --git a/client/ayon_resolve/plugins/publish/collect_audio.py b/client/ayon_resolve/plugins/publish/collect_audio.py index a5fd559725..fb61c297c1 100644 --- a/client/ayon_resolve/plugins/publish/collect_audio.py +++ b/client/ayon_resolve/plugins/publish/collect_audio.py @@ -1,7 +1,7 @@ +import pprint import pyblish -from ayon_resolve.otio import utils - +#from ayon_resolve.otio import utils class CollectAudio(pyblish.api.InstancePlugin): """Collect new audio.""" @@ -16,20 +16,19 @@ def process(self, instance): Args: instance (pyblish.Instance): The shot instance to update. """ - instance.data["folderPath"] = instance.data["folder_path"] - - otio_timeline = instance.context.data["otioTimeline"] - otio_clip, _ = utils.get_marker_from_clip_index( - otio_timeline, instance.data["clip_index"] + # Retrieve instance data from parent instance shot instance. + parent_instance_id = instance.data["parent_instance_id"] + edit_shared_data = instance.context.data["editorialSharedData"] + instance.data.update( + edit_shared_data[parent_instance_id] ) - if not otio_clip: - raise RuntimeError("Could not retrieve otioClip for shot %r", instance) - clip_src = otio_clip.source_range + clip_src = instance.data["otioClip"].source_range clip_src_in = clip_src.start_time.to_frames() clip_src_out = clip_src_in + clip_src.duration.to_frames() instance.data.update({ - "fps": instance.context.data["fps"], "clipInH": clip_src_in, - "clipOutH": clip_src_out, + "clipOutH": clip_src_out }) + + self.log.debug(pprint.pformat(instance.data)) diff --git a/client/ayon_resolve/plugins/publish/collect_plates.py b/client/ayon_resolve/plugins/publish/collect_plates.py index eaed8e4f79..56d1de15a6 100644 --- a/client/ayon_resolve/plugins/publish/collect_plates.py +++ b/client/ayon_resolve/plugins/publish/collect_plates.py @@ -1,7 +1,6 @@ +import pprint import pyblish -from ayon_resolve.otio import utils - class CollectPlate(pyblish.api.InstancePlugin): """Collect new plates.""" @@ -16,17 +15,13 @@ def process(self, instance): Args: instance (pyblish.Instance): The shot instance to update. """ - instance.data["folderPath"] = instance.data["folder_path"] instance.data["families"].append("clip") - # Adjust instance data from parent otio timeline. - otio_timeline = instance.context.data["otioTimeline"] - instance.data["fps"] = instance.context.data["fps"] - - otio_clip, _ = utils.get_marker_from_clip_index( - otio_timeline, instance.data["clip_index"] + # Retrieve instance data from parent instance shot instance. + parent_instance_id = instance.data["parent_instance_id"] + edit_shared_data = instance.context.data["editorialSharedData"] + instance.data.update( + edit_shared_data[parent_instance_id] ) - if not otio_clip: - raise RuntimeError("Could not retrieve otioClip for shot %r", instance) - instance.data["otioClip"] = otio_clip + self.log.debug(pprint.pformat(instance.data)) diff --git a/client/ayon_resolve/plugins/publish/collect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py index f51c3df5d2..c77bc98c00 100644 --- a/client/ayon_resolve/plugins/publish/collect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -1,3 +1,4 @@ +import pprint import pyblish from ayon_resolve.api import lib @@ -7,30 +8,59 @@ class CollectShot(pyblish.api.InstancePlugin): """Collect new shots.""" - order = pyblish.api.CollectorOrder - 0.48 + order = pyblish.api.CollectorOrder - 0.49 label = "Collect Shots" hosts = ["resolve"] families = ["shot"] - @staticmethod - def _prepare_context_hierarchy(instance): + SHARED_KEYS = ( + "folderPath", + "fps", + "otioClip", + ) + + @classmethod + def _inject_editorial_shared_data(cls, instance): + """ + Args: + instance (obj): The publishing instance. """ - TODO: explain - resolve: - https://github.com/ynput/ayon-core/blob/6a07de6eb904c139f6d346fd6f2a7d5042274c71/client/ayon_core/plugins/publish/collect_hierarchy.py#L65 + context = instance.context + instance_id = instance.data["instance_id"] + + # Restore folderPath from creator_attributes to ensure + # new shots/hierarchy are properly handled. + creator_attributes = instance.data['creator_attributes'] + instance.data["folderPath"] = creator_attributes['folderPath'] - traypublisher: - https://github.com/ynput/ayon-traypublisher/blob/develop/client/ayon_traypublisher/plugins/publish/collect_shot_instances.py#L188 + if not context.data.get("editorialSharedData"): + context.data["editorialSharedData"] = {} + + # Inject/Distribute instance shot data as editorialSharedData + # to make it available for clip/plate/audio products + # in sub-collectors. + context.data["editorialSharedData"][instance_id] = { + key: value for key, value in instance.data.items() + if key in cls.SHARED_KEYS + } + + @classmethod + def _compute_resolution_data(cls, instance): + """ + Args: + instance (pyblish.Instance): The shot instance to update. + + Returns: + dict. The resolution data. """ - instance.data["folderPath"] = instance.data["folder_path"] - instance.data["integrate"] = False # no representation for shot + def process(self, instance): """ Args: instance (pyblish.Instance): The shot instance to update. """ - self._prepare_context_hierarchy(instance) + instance.data["integrate"] = False # no representation for shot # Adjust instance data from parent otio timeline. otio_timeline = instance.context.data["otioTimeline"] @@ -82,3 +112,6 @@ def process(self, instance): "pixelAspect": pixel_aspect, } ) + + self._inject_editorial_shared_data(instance) + self.log.debug(pprint.pformat(instance.data)) From 0c2689461ea85700693343180d53989a1f1071e5 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Thu, 5 Sep 2024 15:25:55 -0400 Subject: [PATCH 22/58] Apply suggestions from code review Co-authored-by: Roy Nieterau --- client/ayon_resolve/api/lib.py | 2 +- client/ayon_resolve/api/plugin.py | 2 +- client/ayon_resolve/plugins/create/create_workfile.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 7072b94963..e7be9dfb1a 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -927,7 +927,7 @@ def get_clip_resolution_from_media_pool(timeline_item_data): width = height = None try: - pixel_aspect = int(clip_properties["PAR"]) # Pixel Aspect Resolution + pixel_aspect = int(clip_properties["PAR"]) # Pixel Aspect Resolution except(KeyError, ValueError): pixel_aspect = 1.0 diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index d3f7f04cd4..afc11b2f9d 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -754,7 +754,7 @@ def get_representation_files(project_name, representation): representation (dict): The representation to inspect. Returns: - list. The files associated to the representation. + list: The files associated to the representation. """ anatomy = Anatomy(project_name) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 66c9e10dbf..67dfbabd8f 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -106,7 +106,7 @@ def update_instances(self, update_list): Args: update_list(List[UpdateData]): Gets list of tuples. Each item - contain changed instance and it's changes. + contain changed instance and its changes. """ for created_inst, _ in update_list: data = created_inst.data_to_store() From bea372a0a9b3b7dc34f48688b8e6c730de02cb51 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 5 Sep 2024 17:30:30 -0400 Subject: [PATCH 23/58] Enforce PAR and implemented backward compatibility with OpenPype markers. --- client/ayon_resolve/api/constants.py | 9 ++ client/ayon_resolve/api/lib.py | 10 +- .../plugins/create/create_shot_clip.py | 98 +++++++++++++++++-- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py index de7d2b6498..cb13300c5e 100644 --- a/client/ayon_resolve/api/constants.py +++ b/client/ayon_resolve/api/constants.py @@ -7,6 +7,7 @@ AYON_TAG_NAME = "VFX Notes" # Ayon marker workflow variables +LEGACY_OPENPYPE_MARKER_NAME = "OpenPypeData" AYON_MARKER_NAME = "AyonData" AYON_MARKER_DURATION = 1 AYON_MARKER_COLOR = "Mint" @@ -14,3 +15,11 @@ # Ayon default timeline AYON_TIMELINE_NAME = "AyonTimeline" + +# PAR constants defined by DaVinci Resolve +PAR_VALUES = { + "Square": 1.0, + "16:9 Anamorphic": 1.78, + "4:3 Standard Definition": 1.33, + "Cinemascope": 2.0 +} diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index e7be9dfb1a..3ebffab559 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -622,7 +622,7 @@ def set_ayon_marker(timeline_item, tag_data): ) -def get_ayon_marker(timeline_item): +def get_ayon_marker(timeline_item, tag_name=constants.AYON_MARKER_NAME): timeline_item_markers = timeline_item.GetMarkers() for marker_frame in timeline_item_markers: note = timeline_item_markers[marker_frame]["note"] @@ -630,7 +630,7 @@ def get_ayon_marker(timeline_item): name = timeline_item_markers[marker_frame]["name"] print(f"_ marker data: {marker_frame} | {name} | {color} | {note}") if ( - name == constants.AYON_MARKER_NAME + name == tag_name and color == constants.AYON_MARKER_COLOR ): constants.TEMP_MARKER_FRAME = marker_frame @@ -927,8 +927,10 @@ def get_clip_resolution_from_media_pool(timeline_item_data): width = height = None try: - pixel_aspect = int(clip_properties["PAR"]) # Pixel Aspect Resolution - except(KeyError, ValueError): + clip_par = clip_properties["PAR"] # Pixel Aspect Resolution + pixel_aspect = constants.PAR_VALUES[clip_par] + + except(KeyError, ValueError): # Unknown or undetected PAR pixel_aspect = 1.0 return {"width": width, "height": height, "pixelAspect": pixel_aspect} diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 91e261832c..e41d1f9a23 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -56,7 +56,14 @@ def update_instances(self, update_list): for created_inst, _changes in update_list: track_item = created_inst.transient_data["track_item"] tag_data = lib.get_timeline_item_ayon_tag(track_item) - instances_data = tag_data[_CONTENT_ID] + + try: + instances_data = tag_data[_CONTENT_ID] + + # Backwards compatible (Deprecated since 24/09/05) + except KeyError: + tag_data[_CONTENT_ID] = {} + instances_data = tag_data[_CONTENT_ID] instances_data[self.identifier] = created_inst.data_to_store() lib.imprint(track_item, tag_data) @@ -479,6 +486,75 @@ def create(self, subset_name, instance_data, pre_create_data): return instances + def _create_and_add_instance(self, data, creator_id, + timeline_item, instances): + """ + Args: + data (dict): The data to re-recreate the instance from. + creator_id (str): The creator id to use. + timeline_item (obj): The associated timeline item. + instances (list): Result instance container. + + Returns: + CreatedInstance: The newly created instance. + """ + creator = self.create_context.creators[creator_id] + instance = creator.create(data, None) + instance.transient_data["track_item"] = timeline_item + self._add_instance_to_context(instance) + instances.append(instance) + return instance + + def _handle_legacy_marker(self, tag_data, timeline_item, instances): + """ Convert OpenPypeData to AYON data. + + Args: + tag_data (dict): The legacy marker data. + timline_item (obj): The associated Resolve item. + instances (list): Result instance container. + """ + clip_instances = {} + item_unique_id = timeline_item.GetUniqueId() + tag_data.update({ + "task": self.create_context.get_current_task_name(), + "clip_index": item_unique_id, + "creator_attributes": { + "folderPath": tag_data["folder_path"] + }, + }) + + # create parent shot + creator_id = "io.ayon.creators.resolve.shot" + shot_data = tag_data.copy() + inst = self._create_and_add_instance( + shot_data, creator_id, timeline_item, instances) + clip_instances[creator_id] = inst.data_to_store() + + # create children plate + creator_id = "io.ayon.creators.resolve.plate" + plate_data = tag_data.copy() + plate_data.update({ + "parent_instance_id": inst["instance_id"], + "clip_variant": tag_data["variant"], + "creator_attributes": { + "folderPath": tag_data["folder_path"], + "parent_instance": inst["label"], + } + }) + inst = self._create_and_add_instance( + plate_data, creator_id, timeline_item, instances) + clip_instances[creator_id] = inst.data_to_store() + + # Update marker with new version data. + timeline_item.DeleteMarkersByColor(constants.AYON_MARKER_COLOR) + lib.imprint( + timeline_item, + data={ + _CONTENT_ID: clip_instances, + "clip_index": item_unique_id, + }, + ) + def collect_instances(self): """Collect all created instances from current timeline.""" all_timeline_items = lib.get_current_timeline_items() @@ -486,17 +562,25 @@ def collect_instances(self): for timeline_item_data in all_timeline_items: timeline_item = timeline_item_data["clip"]["item"] - # get openpype tag data + # get (legacy) openpype tag data + # Backwards compatible (Deprecated since 24/09/05) + tag_data = lib.get_ayon_marker( + timeline_item, + tag_name=constants.LEGACY_OPENPYPE_MARKER_NAME + ) + if tag_data: + self._handle_legacy_marker( + tag_data, timeline_item, instances) + continue + + # get AyonData tag data tag_data = lib.get_timeline_item_ayon_tag(timeline_item) if not tag_data: continue for creator_id, data in tag_data.get(_CONTENT_ID, {}).items(): - creator = self.create_context.creators[creator_id] - instance = CreatedInstance.from_existing(data, creator) - instance.transient_data["track_item"] = timeline_item - self._add_instance_to_context(instance) - instances.append(instance) + self._create_and_add_instance( + data, creator_id, timeline_item, instances) return instances From 88828bce2948d47d0438cc86e7f06f380a6028ca Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 5 Sep 2024 17:56:44 -0400 Subject: [PATCH 24/58] Enforce load as clip fix. --- client/ayon_resolve/api/plugin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index a562ebcd26..e1265f1da7 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -1,4 +1,5 @@ import copy +import os import re import uuid @@ -148,9 +149,15 @@ def load(self, files): self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin + + # make sure files list is not empty and first available file exists + filepath = next((f for f in files if os.path.isfile(f)), None) + if not filepath: + raise FileNotFoundError("No file found in input files list") + # create clip media media_pool_item = lib.create_media_pool_item( - files[0], + filepath, self.active_bin ) _clip_property = media_pool_item.GetClipProperty From 9c0fe901e508d2648d9eb69b19d40f8ddba381b3 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Sep 2024 09:55:13 -0400 Subject: [PATCH 25/58] Adjust feedback from PR. --- .../plugins/create/create_shot_clip.py | 98 +++++++++++++++---- .../plugins/publish/collect_shots.py | 26 ++--- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index e41d1f9a23..9adf81d367 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -15,8 +15,40 @@ _CONTENT_ID = "resolve_sub_products" -class _ResolveInstanceCreator(plugin.HiddenResolvePublishCreator): - """Wrapper class for shot product. +# Shot attributes +CLIP_ATTR_DEFS = [ + EnumDef( + "fps", + items=[ + {"value": "from_selection", "label": "From selection"}, + {"value": 23.997, "label": "23.976"}, + {"value": 24, "label": "24"}, + {"value": 25, "label": "25"}, + {"value": 29.97, "label": "29.97"}, + {"value": 30, "label": "30"} + ], + label="FPS" + ), + NumberDef( + "workfileFrameStart", + default=1001, + label="Workfile start frame" + ), + NumberDef( + "handleStart", + default=0, + label="Handle start" + ), + NumberDef( + "handleEnd", + default=0, + label="Handle end" + ) +] + + +class _ResolveInstanceClipCreator(plugin.HiddenResolvePublishCreator): + """Wrapper class for clip types products. """ def create(self, instance_data, _): @@ -30,10 +62,6 @@ def create(self, instance_data, _): """ instance_data.update({ "productName": f"{self.product_type}{instance_data['variant']}", - "label": ( - f"{instance_data['creator_attributes']['folderPath']} " - f"{self.product_type}" - ), "productType": self.product_type, "newHierarchyIntegration": True, # Backwards compatible (Deprecated since 24/06/06) @@ -93,14 +121,40 @@ def remove_instances(self, instances): lib.imprint(track_item, tag_data) -class ResolveShotInstanceCreator(_ResolveInstanceCreator): +class ResolveShotInstanceCreator(_ResolveInstanceClipCreator): """Shot product type creator class""" identifier = "io.ayon.creators.resolve.shot" product_type = "shot" label = "Editorial Shot" + def get_instance_attr_defs(self): + instance_attributes = [ + TextDef( + "folderPath", + label="Folder path", + disabled=True, + ), + ] + instance_attributes.extend(CLIP_ATTR_DEFS) + return instance_attributes + + +class _ResolveInstanceClipCreatorBase(_ResolveInstanceClipCreator): + """ Base clip product creator. + """ -class EditorialPlateInstanceCreator(_ResolveInstanceCreator): + def get_instance_attr_defs(self): + instance_attributes = [ + TextDef( + "parentInstance", + label="Linked to", + disabled=True, + ), + ] + return instance_attributes + + +class EditorialPlateInstanceCreator(_ResolveInstanceClipCreatorBase): """Plate product type creator class""" identifier = "io.ayon.creators.resolve.plate" product_type = "plate" @@ -124,7 +178,7 @@ def create(self, instance_data, _): return super().create(instance_data, None) -class EditorialAudioInstanceCreator(_ResolveInstanceCreator): +class EditorialAudioInstanceCreator(_ResolveInstanceClipCreatorBase): """Audio product type creator class""" identifier = "io.ayon.creators.resolve.audio" product_type = "audio" @@ -436,6 +490,7 @@ def create(self, subset_name, instance_data, pre_create_data): clip_instances = {} shot_creator_id = "io.ayon.creators.resolve.shot" for creator_id in enabled_creators: + creator = self.create_context.creators[creator_id] sub_instance_data = copy.deepcopy(instance_data) shot_folder_path = sub_instance_data.pop("target_folder_path") @@ -444,11 +499,14 @@ def create(self, subset_name, instance_data, pre_create_data): sub_instance_data.update({ "creator_attributes": { "folderPath": shot_folder_path, - "workfile_start_frame": \ + "workfileFrameStart": \ sub_instance_data["workfileFrameStart"], - "handle_start": sub_instance_data["handleStart"], - "handle_end": sub_instance_data["handleEnd"] - } + "handleStart": sub_instance_data["handleStart"], + "handleEnd": sub_instance_data["handleEnd"] + }, + "label": ( + f"{shot_folder_path} shot" + ), }) # Plate, Audio @@ -458,15 +516,16 @@ def create(self, subset_name, instance_data, pre_create_data): parenting_data = clip_instances[shot_creator_id] sub_instance_data.update({ "parent_instance_id": parenting_data["instance_id"], + "label": ( + f"{shot_folder_path} " + f"{creator.product_type}" + ), "creator_attributes": { - "folderPath": shot_folder_path, - "parent_instance": parenting_data["label"], + "parentInstance": parenting_data["label"], } }) - instance = self.create_context.creators[creator_id].create( - sub_instance_data, None - ) + instance = creator.create(sub_instance_data, None) instance.transient_data["track_item"] = track_item self._add_instance_to_context(instance) clip_instances[creator_id] = instance.data_to_store() @@ -537,8 +596,7 @@ def _handle_legacy_marker(self, tag_data, timeline_item, instances): "parent_instance_id": inst["instance_id"], "clip_variant": tag_data["variant"], "creator_attributes": { - "folderPath": tag_data["folder_path"], - "parent_instance": inst["label"], + "parentInstance": inst["label"], } }) inst = self._create_and_add_instance( diff --git a/client/ayon_resolve/plugins/publish/collect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py index c77bc98c00..ed5eb35aeb 100644 --- a/client/ayon_resolve/plugins/publish/collect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -28,33 +28,22 @@ def _inject_editorial_shared_data(cls, instance): context = instance.context instance_id = instance.data["instance_id"] - # Restore folderPath from creator_attributes to ensure + # Inject folderPath and other creator_attributes to ensure # new shots/hierarchy are properly handled. creator_attributes = instance.data['creator_attributes'] - instance.data["folderPath"] = creator_attributes['folderPath'] - - if not context.data.get("editorialSharedData"): - context.data["editorialSharedData"] = {} + instance.data.update(creator_attributes) # Inject/Distribute instance shot data as editorialSharedData # to make it available for clip/plate/audio products # in sub-collectors. + if not context.data.get("editorialSharedData"): + context.data["editorialSharedData"] = {} + context.data["editorialSharedData"][instance_id] = { key: value for key, value in instance.data.items() if key in cls.SHARED_KEYS } - @classmethod - def _compute_resolution_data(cls, instance): - """ - Args: - instance (pyblish.Instance): The shot instance to update. - - Returns: - dict. The resolution data. - """ - - def process(self, instance): """ Args: @@ -70,6 +59,10 @@ def process(self, instance): if not otio_clip: raise RuntimeError("Could not retrieve otioClip for shot %r", instance) + # Compute fps from creator attribute. + if instance.data['creator_attributes']["fps"] == "from_selection": + instance.data['creator_attributes']["fps"] = instance.context.data["fps"] + # Retrieve AyonData marker for associated clip. instance.data["otioClip"] = otio_clip creator_id = instance.data["creator_identifier"] @@ -106,7 +99,6 @@ def process(self, instance): instance.data.update( { - "fps": instance.context.data["fps"], "resolutionWidth": width, "resolutionHeight": height, "pixelAspect": pixel_aspect, From d607ae6efa5abfeef69b41dfbcdca861821fff94 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 9 Sep 2024 09:56:19 -0400 Subject: [PATCH 26/58] Update client/ayon_resolve/api/lib.py Co-authored-by: Roy Nieterau --- client/ayon_resolve/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 3ebffab559..4d75b0ebfc 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -930,7 +930,7 @@ def get_clip_resolution_from_media_pool(timeline_item_data): clip_par = clip_properties["PAR"] # Pixel Aspect Resolution pixel_aspect = constants.PAR_VALUES[clip_par] - except(KeyError, ValueError): # Unknown or undetected PAR + except (KeyError, ValueError): # Unknown or undetected PAR pixel_aspect = 1.0 return {"width": width, "height": height, "pixelAspect": pixel_aspect} From 72fb3f1a0b1f8d935f84fa503908546a6cebe181 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Sep 2024 10:05:00 -0400 Subject: [PATCH 27/58] Adjust workfile auto-creation from PR feedback. --- .../plugins/create/create_workfile.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 67dfbabd8f..09fa9c2034 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -89,17 +89,23 @@ def collect_instances(self): """Collect from timeline marker or create a new one.""" data = self._loads_data_from_project_setting() if not data: - self.log.info("Auto-creating workfile instance...") - data = self._create_new_instance() + return current_instance = CreatedInstance( self.product_type, data["productName"], data, self) self._add_instance_to_context(current_instance) def create(self, options=None): - # no need to create if it is created - # in `collect_instances` - pass + """Auto-create an instance by default.""" + data = self._loads_data_from_project_setting() + if data: + return + + self.log.info("Auto-creating workfile instance...") + data = self._create_new_instance() + current_instance = CreatedInstance( + self.product_type, data["productName"], data, self) + self._add_instance_to_context(current_instance) def update_instances(self, update_list): """Store changes in project metadata so they can be recollected. From f4c0406eb4159abb546f12ce4f21e5bc3c0f0391 Mon Sep 17 00:00:00 2001 From: robin Date: Mon, 16 Sep 2024 11:37:17 -0400 Subject: [PATCH 28/58] wip --- client/ayon_resolve/api/plugin.py | 18 +++++------------- .../plugins/create/create_shot_clip.py | 5 +---- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index e1265f1da7..27e3a5c23e 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -5,18 +5,15 @@ import qargparse -from ayon_core.lib import BoolDef - from ayon_core.pipeline import ( LoaderPlugin, Creator, + HiddenCreator, + CreatedInstance, Anatomy ) from ayon_core.pipeline.create import ( - Creator, - HiddenCreator, - CreatedInstance, cache_and_get_instances, ) @@ -333,14 +330,6 @@ def create(self, subset_name, instance_data, pre_create_data): else: self.selected = lib.get_current_timeline_items(filter=False) - # TODO: Add a way to store/imprint data - - def get_pre_create_attr_defs(self): - return [ - BoolDef("use_selection", - label="Use selection", - default=True) - ] # alias for backward compatibility Creator = ResolveCreator # noqa @@ -712,12 +701,14 @@ def _store_new_instance(self, new_instance): """ # Host implementation of storing metadata about instance + # TODO investigate HostContext !! HostContext.add_instance(new_instance.data_to_store()) # Add instance to current context self._add_instance_to_context(new_instance) class ResolvePublishCreator(Creator): + # TODO investigate create_allow_context_change = True host_name = "resolve" settings_category = "resolve" @@ -747,6 +738,7 @@ def _store_new_instance(self, new_instance): """ # Host implementation of storing metadata about instance + # TODO investigate HostContext !! HostContext.add_instance(new_instance.data_to_store()) new_instance.mark_as_stored() diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 9adf81d367..ed55834d3e 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -384,9 +384,6 @@ def header_label(text): ), ] - presets = None - rename_index = 0 - def create(self, subset_name, instance_data, pre_create_data): super(CreateShotClip, self).create(subset_name, instance_data, @@ -452,7 +449,7 @@ def create(self, subset_name, instance_data, pre_create_data): item_unique_id = track_item_data["clip"]["item"].GetUniqueId() instance_data.update({ "clip_index": item_unique_id, - "clip_source_resolution": resolution_data, + "clip_source_resolution": resolution_data, # TODO investigate in collect }) # convert track item to timeline media pool item From c0cd3328e40df12f6fc66a8435b841aeb56dc410 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 16 Sep 2024 11:43:18 -0400 Subject: [PATCH 29/58] Adjust current workfile. --- client/ayon_resolve/api/plugin.py | 3 ++- client/ayon_resolve/plugins/create/create_shot_clip.py | 4 +++- client/ayon_resolve/plugins/publish/extract_workfile.py | 4 +--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index e1265f1da7..c9bed2ae85 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -468,7 +468,8 @@ def convert(self): self.tag_data["hierarchy"], self.tag_data["asset"], ) - self.tag_data["target_folder_path"] = folder_path +# self.tag_data["target_folder_path"] = folder_path + self.tag_data["folderPath"] = folder_path if not constants.AYON_MARKER_WORKFLOW: # create compound clip workflow diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 9adf81d367..202daf6f2c 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -63,6 +63,7 @@ def create(self, instance_data, _): instance_data.update({ "productName": f"{self.product_type}{instance_data['variant']}", "productType": self.product_type, + "has_promised_context": True, "newHierarchyIntegration": True, # Backwards compatible (Deprecated since 24/06/06) "newAssetPublishing": True, @@ -492,7 +493,8 @@ def create(self, subset_name, instance_data, pre_create_data): for creator_id in enabled_creators: creator = self.create_context.creators[creator_id] sub_instance_data = copy.deepcopy(instance_data) - shot_folder_path = sub_instance_data.pop("target_folder_path") +# shot_folder_path = sub_instance_data.pop("target_folder_path") + shot_folder_path = sub_instance_data["folderPath"] # Shot creation if creator_id == shot_creator_id: diff --git a/client/ayon_resolve/plugins/publish/extract_workfile.py b/client/ayon_resolve/plugins/publish/extract_workfile.py index a4b00141a8..d0a957a4eb 100644 --- a/client/ayon_resolve/plugins/publish/extract_workfile.py +++ b/client/ayon_resolve/plugins/publish/extract_workfile.py @@ -2,7 +2,6 @@ import pyblish.api from ayon_core.pipeline import publish -from ayon_core.pipeline import registered_host from ayon_resolve.api.lib import get_project_manager @@ -20,8 +19,7 @@ class ExtractWorkfile(publish.Extractor): def process(self, instance): project = instance.context.data["activeProject"] - host = registered_host() - drp_file_path = host.get_current_workfile() + drp_file_path = instance.data["currentFile"] drp_file_name = os.path.basename(drp_file_path) # write out the drp workfile From f24345cc6f372046d23127c669682bb9a9f8a840 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 16 Sep 2024 16:54:43 -0400 Subject: [PATCH 30/58] Address changes from PR. --- client/ayon_resolve/api/plugin.py | 3 +- .../plugins/create/create_shot_clip.py | 83 +++++++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index c9bed2ae85..e1265f1da7 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -468,8 +468,7 @@ def convert(self): self.tag_data["hierarchy"], self.tag_data["asset"], ) -# self.tag_data["target_folder_path"] = folder_path - self.tag_data["folderPath"] = folder_path + self.tag_data["target_folder_path"] = folder_path if not constants.AYON_MARKER_WORKFLOW: # create compound clip workflow diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 202daf6f2c..eb9cfbb6ea 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -43,7 +43,49 @@ "handleEnd", default=0, label="Handle end" - ) + ), + NumberDef( + "frameStart", + default=0, + label="Frame start", + disabled=True, + ), + NumberDef( + "frameEnd", + default=0, + label="Frame end", + disabled=True, + ), + NumberDef( + "clipIn", + default=0, + label="Clip in", + disabled=True, + ), + NumberDef( + "clipOut", + default=0, + label="Clip out", + disabled=True, + ), + NumberDef( + "clipDuration", + default=0, + label="Clip duration", + disabled=True, + ), + NumberDef( + "sourceIn", + default=0, + label="Media source in", + disabled=True, + ), + NumberDef( + "sourceOut", + default=0, + label="Media source out", + disabled=True, + ) ] @@ -134,7 +176,7 @@ def get_instance_attr_defs(self): "folderPath", label="Folder path", disabled=True, - ), + ) ] instance_attributes.extend(CLIP_ATTR_DEFS) return instance_attributes @@ -145,12 +187,27 @@ class _ResolveInstanceClipCreatorBase(_ResolveInstanceClipCreator): """ def get_instance_attr_defs(self): + gui_tracks = get_video_track_names() instance_attributes = [ TextDef( "parentInstance", label="Linked to", disabled=True, ), + BoolDef( + "vSyncOn", + label="Enable Vertical Sync", + tooltip="Switch on if you want clips above " + "each other to share its attributes", + default=True, + ), + EnumDef( + "vSyncTrack", + label="Hero Track", + tooltip="Select driving track name which should " + "be mastering all others", + items=gui_tracks or [""], + ), ] return instance_attributes @@ -493,18 +550,28 @@ def create(self, subset_name, instance_data, pre_create_data): for creator_id in enabled_creators: creator = self.create_context.creators[creator_id] sub_instance_data = copy.deepcopy(instance_data) -# shot_folder_path = sub_instance_data.pop("target_folder_path") - shot_folder_path = sub_instance_data["folderPath"] + shot_folder_path = sub_instance_data.pop("target_folder_path") # Shot creation if creator_id == shot_creator_id: + track_item_duration = track_item.GetDuration() + workfileFrameStart = \ + sub_instance_data["workfileFrameStart"] sub_instance_data.update({ "creator_attributes": { "folderPath": shot_folder_path, - "workfileFrameStart": \ - sub_instance_data["workfileFrameStart"], + "workfileFrameStart": workfileFrameStart, "handleStart": sub_instance_data["handleStart"], - "handleEnd": sub_instance_data["handleEnd"] + "handleEnd": sub_instance_data["handleEnd"], + "frameStart": workfileFrameStart, + "frameEnd": (workfileFrameStart + + track_item_duration), + "clipIn": track_item.GetStart(), + "clipOut": track_item.GetEnd(), + "clipDuration": track_item_duration, + "sourceIn": track_item.GetLeftOffset(), + "sourceOut": (track_item.GetLeftOffset() + + track_item_duration), }, "label": ( f"{shot_folder_path} shot" @@ -524,6 +591,8 @@ def create(self, subset_name, instance_data, pre_create_data): ), "creator_attributes": { "parentInstance": parenting_data["label"], + "vSyncOn": pre_create_data["vSyncOn"], + "vSyncTrack": pre_create_data["vSyncTrack"], } }) From 9271596824c1a087723430e28fe462685b8b7fcd Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 16 Sep 2024 17:17:24 -0400 Subject: [PATCH 31/58] Implement feedback from PR. --- client/ayon_resolve/api/pipeline.py | 135 ------------------ client/ayon_resolve/api/plugin.py | 57 +------- .../plugins/create/create_shot_clip.py | 2 +- .../plugins/publish/extract_workfile.py | 2 +- 4 files changed, 4 insertions(+), 192 deletions(-) diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 2c95db47a6..688404c4db 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -286,138 +286,3 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): # Whether instances should be passthrough based on new value timeline_item = instance.data["item"] set_publish_attribute(timeline_item, new_value) - - -class HostContext: - _context_json_path = None - - @staticmethod - def _on_exit(): - if ( - HostContext._context_json_path - and os.path.exists(HostContext._context_json_path) - ): - os.remove(HostContext._context_json_path) - - @classmethod - def get_context_json_path(cls): - if cls._context_json_path is None: - output_file = tempfile.NamedTemporaryFile( - mode="w", prefix="resolve_", suffix=".json" - ) - output_file.close() - cls._context_json_path = output_file.name - atexit.register(HostContext._on_exit) - print(cls._context_json_path) - return cls._context_json_path - - @classmethod - def _get_data(cls, group=None): - json_path = cls.get_context_json_path() - data = {} - if not os.path.exists(json_path): - with open(json_path, "w") as json_stream: - json.dump(data, json_stream) - else: - with open(json_path, "r") as json_stream: - content = json_stream.read() - if content: - data = json.loads(content) - if group is None: - return data - return data.get(group) - - @classmethod - def _save_data(cls, group, new_data): - json_path = cls.get_context_json_path() - data = cls._get_data() - data[group] = new_data - with open(json_path, "w") as json_stream: - json.dump(data, json_stream) - - @classmethod - def add_instance(cls, instance): - instances = cls.get_instances() - instances.append(instance) - cls.save_instances(instances) - - @classmethod - def get_instances(cls): - return cls._get_data("instances") or [] - - @classmethod - def save_instances(cls, instances): - cls._save_data("instances", instances) - - @classmethod - def get_context_data(cls): - return cls._get_data("context") or {} - - @classmethod - def save_context_data(cls, data): - cls._save_data("context", data) - - @classmethod - def get_project_name(cls): - return cls._get_data("project_name") - - @classmethod - def set_project_name(cls, project_name): - cls._save_data("project_name", project_name) - - @classmethod - def get_data_to_store(cls): - return { - "project_name": cls.get_project_name(), - "instances": cls.get_instances(), - "context": cls.get_context_data(), - } - - -def list_instances(): - return HostContext.get_instances() - - -def update_instances(update_list): - updated_instances = {} - for instance, _changes in update_list: - updated_instances[instance.id] = instance.data_to_store() - - instances = HostContext.get_instances() - for instance_data in instances: - instance_id = instance_data["instance_id"] - if instance_id in updated_instances: - new_instance_data = updated_instances[instance_id] - old_keys = set(instance_data.keys()) - new_keys = set(new_instance_data.keys()) - instance_data.update(new_instance_data) - for key in (old_keys - new_keys): - instance_data.pop(key) - - HostContext.save_instances(instances) - - -def remove_instances(instances): - if not isinstance(instances, (tuple, list)): - instances = [instances] - - current_instances = HostContext.get_instances() - for instance in instances: - instance_id = instance.data["instance_id"] - found_idx = None - for idx, _instance in enumerate(current_instances): - if instance_id == _instance["instance_id"]: - found_idx = idx - break - - if found_idx is not None: - current_instances.pop(found_idx) - HostContext.save_instances(current_instances) - - -def get_context_data(): - return HostContext.get_context_data() - - -def update_context_data(data, changes): - HostContext.save_context_data(data) \ No newline at end of file diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 27e3a5c23e..d7ebbdbf3f 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -13,17 +13,6 @@ Anatomy ) -from ayon_core.pipeline.create import ( - cache_and_get_instances, -) - -from .pipeline import ( - list_instances, - update_instances, - remove_instances, - HostContext, -) - from . import lib, constants @@ -686,40 +675,16 @@ def update_instances(self, update_list): pass def remove_instances(self, instances): - remove_instances(instances) - for instance in instances: - self._remove_instance_from_context(instance) - - def _store_new_instance(self, new_instance): - """Resolve publisher specific method to store instance. - - Instance is stored into "workfile" of Resolve and also add it - to CreateContext. - - Args: - new_instance (CreatedInstance): Instance that should be stored. - """ - - # Host implementation of storing metadata about instance - # TODO investigate HostContext !! - HostContext.add_instance(new_instance.data_to_store()) - # Add instance to current context - self._add_instance_to_context(new_instance) + pass class ResolvePublishCreator(Creator): - # TODO investigate create_allow_context_change = True host_name = "resolve" settings_category = "resolve" def collect_instances(self): - instances_by_identifier = cache_and_get_instances( - self, SHARED_DATA_KEY, list_instances - ) - for instance_data in instances_by_identifier[self.identifier]: - instance = CreatedInstance.from_existing(instance_data, self) - self._add_instance_to_context(instance) + pass def update_instances(self, update_list): pass @@ -727,24 +692,6 @@ def update_instances(self, update_list): def remove_instances(self, instances): pass - def _store_new_instance(self, new_instance): - """Resolve publisher specific method to store instance. - - Instance is stored into "workfile" of Resolve and also add it - to CreateContext. - - Args: - new_instance (CreatedInstance): Instance that should be stored. - """ - - # Host implementation of storing metadata about instance - # TODO investigate HostContext !! - HostContext.add_instance(new_instance.data_to_store()) - new_instance.mark_as_stored() - - # Add instance to current context - self._add_instance_to_context(new_instance) - def get_representation_files(project_name, representation): """ diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 999b9fed87..880910acff 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -114,7 +114,7 @@ def create(self, instance_data, _): new_instance = CreatedInstance( self.product_type, instance_data["productName"], instance_data, self ) - self._store_new_instance(new_instance) + self._add_instance_to_context(new_instance) return new_instance def update_instances(self, update_list): diff --git a/client/ayon_resolve/plugins/publish/extract_workfile.py b/client/ayon_resolve/plugins/publish/extract_workfile.py index d0a957a4eb..e0801f76f6 100644 --- a/client/ayon_resolve/plugins/publish/extract_workfile.py +++ b/client/ayon_resolve/plugins/publish/extract_workfile.py @@ -19,7 +19,7 @@ class ExtractWorkfile(publish.Extractor): def process(self, instance): project = instance.context.data["activeProject"] - drp_file_path = instance.data["currentFile"] + drp_file_path = instance.context.data["currentFile"] drp_file_name = os.path.basename(drp_file_path) # write out the drp workfile From b37ae7b9b26266d4dd7db4cea10a89d5a60667b5 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 17 Sep 2024 08:05:07 -0400 Subject: [PATCH 32/58] Report has_promised_context transient data. --- client/ayon_resolve/api/plugin.py | 2 +- .../plugins/create/create_shot_clip.py | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index d7ebbdbf3f..3fe9833f3a 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -446,7 +446,7 @@ def convert(self): self.tag_data["hierarchy"], self.tag_data["asset"], ) - self.tag_data["target_folder_path"] = folder_path + self.tag_data["folderPath"] = folder_path if not constants.AYON_MARKER_WORKFLOW: # create compound clip workflow diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 880910acff..32bfdf1197 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -115,6 +115,7 @@ def create(self, instance_data, _): self.product_type, instance_data["productName"], instance_data, self ) self._add_instance_to_context(new_instance) + new_instance.transient_data["has_promised_context"] = True return new_instance def update_instances(self, update_list): @@ -193,22 +194,26 @@ def get_instance_attr_defs(self): "parentInstance", label="Linked to", disabled=True, - ), - BoolDef( - "vSyncOn", - label="Enable Vertical Sync", - tooltip="Switch on if you want clips above " - "each other to share its attributes", - default=True, - ), - EnumDef( - "vSyncTrack", - label="Hero Track", - tooltip="Select driving track name which should " - "be mastering all others", - items=gui_tracks or [""], - ), + ) ] + if self.product_type == "plate": + instance_attributes.extend([ + BoolDef( + "vSyncOn", + label="Enable Vertical Sync", + tooltip="Switch on if you want clips above " + "each other to share its attributes", + default=True, + ), + EnumDef( + "vSyncTrack", + label="Hero Track", + tooltip="Select driving track name which should " + "be mastering all others", + items=gui_tracks or [""], + ), + ]) + return instance_attributes @@ -547,7 +552,7 @@ def create(self, subset_name, instance_data, pre_create_data): for creator_id in enabled_creators: creator = self.create_context.creators[creator_id] sub_instance_data = copy.deepcopy(instance_data) - shot_folder_path = sub_instance_data.pop("target_folder_path") + shot_folder_path = sub_instance_data["folderPath"] # Shot creation if creator_id == shot_creator_id: @@ -588,11 +593,15 @@ def create(self, subset_name, instance_data, pre_create_data): ), "creator_attributes": { "parentInstance": parenting_data["label"], - "vSyncOn": pre_create_data["vSyncOn"], - "vSyncTrack": pre_create_data["vSyncTrack"], } }) + if creator_id == "io.ayon.creators.resolve.plate": + sub_instance_data["creator_attributes"].update({ + "vSyncOn": pre_create_data["vSyncOn"], + "vSyncTrack": pre_create_data["vSyncTrack"], + }) + instance = creator.create(sub_instance_data, None) instance.transient_data["track_item"] = track_item self._add_instance_to_context(instance) From 20be4c6891952f38bef6cc688de3ebb67a4e3f14 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 17 Sep 2024 08:12:47 -0400 Subject: [PATCH 33/58] Fix lint. --- client/ayon_resolve/api/pipeline.py | 3 --- client/ayon_resolve/api/plugin.py | 1 - client/ayon_resolve/plugins/create/create_workfile.py | 1 - 3 files changed, 5 deletions(-) diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 688404c4db..9a8c68771d 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -4,9 +4,6 @@ import os import json import contextlib -import atexit -import tempfile -import json from collections import OrderedDict from pyblish import api as pyblish diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 3fe9833f3a..c79fded005 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -9,7 +9,6 @@ LoaderPlugin, Creator, HiddenCreator, - CreatedInstance, Anatomy ) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 09fa9c2034..f476008574 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -9,7 +9,6 @@ ) from ayon_resolve.api import lib -from ayon_resolve.api import constants class CreateWorkfile(AutoCreator): From a7c3b64c7b4bb0d9bd1e53bd88abb58acde85aec Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 24 Sep 2024 17:35:36 -0400 Subject: [PATCH 34/58] Report fixes from review testing. --- client/ayon_resolve/api/plugin.py | 2 +- client/ayon_resolve/plugins/publish/collect_shots.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index c79fded005..5b80ac6922 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -396,7 +396,7 @@ def __init__( # get track name and index track_name = timeline_item_data["track"]["name"] - self.track_name = str(track_name).replace(" ", "_") + self.track_name = str(track_name).replace(" ", "_") # TODO clarify self.track_index = int(timeline_item_data["track"]["index"]) # adding ui inputs if any diff --git a/client/ayon_resolve/plugins/publish/collect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py index ed5eb35aeb..ee7a295eb2 100644 --- a/client/ayon_resolve/plugins/publish/collect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -17,6 +17,9 @@ class CollectShot(pyblish.api.InstancePlugin): "folderPath", "fps", "otioClip", + "resolutionWidth", + "resolutionHeight", + "pixelAspect", ) @classmethod From ca1034c96c3d82c1a5bed7036f827a931ffac870 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 25 Sep 2024 13:29:57 -0400 Subject: [PATCH 35/58] Consolidations --- client/ayon_resolve/api/lib.py | 2 +- client/ayon_resolve/plugins/create/create_shot_clip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 854917c787..29056f7bce 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -654,7 +654,7 @@ def set_ayon_marker(timeline_item, tag_data): def get_ayon_marker(timeline_item, tag_name=constants.AYON_MARKER_NAME): - timeline_item_markers = timeline_item.GetMarkers() + timeline_item_markers = timeline_item.GetMarkers() or [] for marker_frame in timeline_item_markers: note = timeline_item_markers[marker_frame]["note"] color = timeline_item_markers[marker_frame]["color"] diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 32bfdf1197..f05d692a00 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -512,7 +512,7 @@ def create(self, subset_name, instance_data, pre_create_data): item_unique_id = track_item_data["clip"]["item"].GetUniqueId() instance_data.update({ "clip_index": item_unique_id, - "clip_source_resolution": resolution_data, # TODO investigate in collect + "clip_source_resolution": resolution_data, }) # convert track item to timeline media pool item From 1b6e9aa353dc88d86d51ec3f41920e27ee0e441d Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 25 Sep 2024 16:02:08 -0400 Subject: [PATCH 36/58] Address typo feedback from PR. --- client/ayon_resolve/README.markdown | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_resolve/README.markdown b/client/ayon_resolve/README.markdown index b16a654538..3800eed7a7 100644 --- a/client/ayon_resolve/README.markdown +++ b/client/ayon_resolve/README.markdown @@ -10,7 +10,7 @@ ![image](https://user-images.githubusercontent.com/40640033/102792588-ffcb1c80-43a8-11eb-9c6b-bf2114ed578e.png) with installed CMake in PATH. - make sure Resolve Fusion (Fusion Tab/menu/Fusion/Fusion Settings) is set to Python 3.6 ![image](https://user-images.githubusercontent.com/40640033/102631545-280b0f00-414e-11eb-89fc-98ac268d209d.png) -- Open Ayon **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. +- Open AYON **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. ## Editorial setup @@ -18,9 +18,9 @@ This is how it looks on my testing project timeline ![image](https://user-images.githubusercontent.com/40640033/102637638-96ec6600-4156-11eb-9656-6e8e3ce4baf8.png) Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg confersion to jpg sequence. -1. you need to start Ayon menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__Ayon_Menu__** +1. you need to start AYON menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__AYON_Menu__** 2. then select any clips in `main` track and change their color to `Chocolate` -3. in Ayon Menu select `Create` +3. in AYON Menu select `Create` 4. in Creator select `Create Publishable Clip [New]` (temporary name) 5. set `Rename clips` to True, Master Track to `main` and Use review track to `review` as in picture ![image](https://user-images.githubusercontent.com/40640033/102643773-0d419600-4160-11eb-919e-9c2be0aecab8.png) From 5a8ac901e63022db5bcd89230158012b9688f67e Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 30 Sep 2024 10:20:04 -0400 Subject: [PATCH 37/58] Apply suggestions from code review Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: Roy Nieterau --- client/ayon_resolve/api/constants.py | 4 ++-- .../ayon_resolve/plugins/create/create_workfile.py | 12 ++++-------- .../ayon_resolve/plugins/publish/collect_plates.py | 2 -- client/ayon_resolve/plugins/publish/collect_shots.py | 1 - .../utility_scripts/ayon_startup.scriptlib | 4 ++-- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/client/ayon_resolve/api/constants.py b/client/ayon_resolve/api/constants.py index cb13300c5e..0e587a7dd0 100644 --- a/client/ayon_resolve/api/constants.py +++ b/client/ayon_resolve/api/constants.py @@ -8,13 +8,13 @@ # Ayon marker workflow variables LEGACY_OPENPYPE_MARKER_NAME = "OpenPypeData" -AYON_MARKER_NAME = "AyonData" +AYON_MARKER_NAME = "AYONData" AYON_MARKER_DURATION = 1 AYON_MARKER_COLOR = "Mint" TEMP_MARKER_FRAME = None # Ayon default timeline -AYON_TIMELINE_NAME = "AyonTimeline" +AYON_TIMELINE_NAME = "AYONTimeline" # PAR constants defined by DaVinci Resolve PAR_VALUES = { diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index f476008574..1e8a8bd41d 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -49,15 +49,11 @@ def _create_new_instance(self): """Create new instance.""" variant = self.default_variant project_name = self.create_context.get_current_project_name() - folder_path = self.create_context.get_current_folder_path() - task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name - - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path) - task_entity = ayon_api.get_task_by_name( - project_name, folder_entity["id"], task_name - ) + folder_entity = self.get_current_folder_entity() + task_entity = self.get_current_task_entity() + folder_path = folder_entity["path"] + task_name = task_entity["name"] product_name = self.get_product_name( project_name, folder_entity, diff --git a/client/ayon_resolve/plugins/publish/collect_plates.py b/client/ayon_resolve/plugins/publish/collect_plates.py index 56d1de15a6..8427e815f6 100644 --- a/client/ayon_resolve/plugins/publish/collect_plates.py +++ b/client/ayon_resolve/plugins/publish/collect_plates.py @@ -23,5 +23,3 @@ def process(self, instance): instance.data.update( edit_shared_data[parent_instance_id] ) - - self.log.debug(pprint.pformat(instance.data)) diff --git a/client/ayon_resolve/plugins/publish/collect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py index ee7a295eb2..87b09800e9 100644 --- a/client/ayon_resolve/plugins/publish/collect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -109,4 +109,3 @@ def process(self, instance): ) self._inject_editorial_shared_data(instance) - self.log.debug(pprint.pformat(instance.data)) diff --git a/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib b/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib index ecc75946b5..26cc15c1e0 100644 --- a/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib +++ b/client/ayon_resolve/utility_scripts/ayon_startup.scriptlib @@ -1,4 +1,4 @@ --- Run Ayon's Python launch script for resolve +-- Run AYON's Python launch script for resolve function file_exists(name) local f = io.open(name, "r") return f ~= nil and io.close(f) @@ -12,7 +12,7 @@ if ayon_startup_script ~= nil then if file_exists(script) then -- We must use RunScript to ensure it runs in a separate -- process to Resolve itself to avoid a deadlock for - -- certain imports of Ayon libraries or Qt + -- certain imports of AYON libraries or Qt print("Running launch script: " .. script) fusion:RunScript(script) else From d0c69f6afb548c30558641ec3b53078f8eeb1df1 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 10:23:43 -0400 Subject: [PATCH 38/58] Fix linting. --- client/ayon_resolve/plugins/create/create_workfile.py | 1 - client/ayon_resolve/plugins/publish/collect_plates.py | 1 - client/ayon_resolve/plugins/publish/collect_shots.py | 1 - 3 files changed, 3 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index 1e8a8bd41d..d6dfc6457c 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -2,7 +2,6 @@ """Creator plugin for creating workfiles.""" import json -import ayon_api from ayon_core.pipeline import ( AutoCreator, CreatedInstance, diff --git a/client/ayon_resolve/plugins/publish/collect_plates.py b/client/ayon_resolve/plugins/publish/collect_plates.py index 8427e815f6..2c92c9eb9d 100644 --- a/client/ayon_resolve/plugins/publish/collect_plates.py +++ b/client/ayon_resolve/plugins/publish/collect_plates.py @@ -1,4 +1,3 @@ -import pprint import pyblish diff --git a/client/ayon_resolve/plugins/publish/collect_shots.py b/client/ayon_resolve/plugins/publish/collect_shots.py index 87b09800e9..8471e564bd 100644 --- a/client/ayon_resolve/plugins/publish/collect_shots.py +++ b/client/ayon_resolve/plugins/publish/collect_shots.py @@ -1,4 +1,3 @@ -import pprint import pyblish from ayon_resolve.api import lib From 9e38605d2973e263175b4ed5b000f67fb0b90cdd Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 11:54:18 -0400 Subject: [PATCH 39/58] Adjust feedback from PR. --- client/ayon_resolve/api/plugin.py | 1 + .../plugins/create/create_shot_clip.py | 16 ++++------------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 888e6572e4..5df58b7de5 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -507,6 +507,7 @@ def get(key): self.product_type = get("productType") or self.product_type_default self.vertical_sync = get("vSyncOn") or self.vertical_sync_default self.hero_track = get("vSyncTrack") or self.driving_layer_default + self.hero_track = self.hero_track.replace(" ", "_") self.review_media_track = ( get("reviewTrack") or self.review_track_default) diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index f05d692a00..53f77eb26d 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -110,6 +110,7 @@ def create(self, instance_data, _): # Backwards compatible (Deprecated since 24/06/06) "newAssetPublishing": True, }) + instance_data["folder"] = instance_data["folderPath"] new_instance = CreatedInstance( self.product_type, instance_data["productName"], instance_data, self @@ -172,14 +173,7 @@ class ResolveShotInstanceCreator(_ResolveInstanceClipCreator): label = "Editorial Shot" def get_instance_attr_defs(self): - instance_attributes = [ - TextDef( - "folderPath", - label="Folder path", - disabled=True, - ) - ] - instance_attributes.extend(CLIP_ATTR_DEFS) + instance_attributes = CLIP_ATTR_DEFS return instance_attributes @@ -453,6 +447,8 @@ def create(self, subset_name, instance_data, pre_create_data): pre_create_data) instance_data["clip_variant"] = pre_create_data["clip_variant"] + instance_data["task"] = None + if not self.timeline: raise CreatorError( @@ -561,7 +557,6 @@ def create(self, subset_name, instance_data, pre_create_data): sub_instance_data["workfileFrameStart"] sub_instance_data.update({ "creator_attributes": { - "folderPath": shot_folder_path, "workfileFrameStart": workfileFrameStart, "handleStart": sub_instance_data["handleStart"], "handleEnd": sub_instance_data["handleEnd"], @@ -654,9 +649,6 @@ def _handle_legacy_marker(self, tag_data, timeline_item, instances): tag_data.update({ "task": self.create_context.get_current_task_name(), "clip_index": item_unique_id, - "creator_attributes": { - "folderPath": tag_data["folder_path"] - }, }) # create parent shot From da9f148c05054172b9967b18604298d319d625fb Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 12:18:06 -0400 Subject: [PATCH 40/58] Update documentation. --- client/ayon_resolve/README.markdown | 15 ++++++++------- client/ayon_resolve/doc_examples.jpg | Bin 0 -> 155987 bytes 2 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 client/ayon_resolve/doc_examples.jpg diff --git a/client/ayon_resolve/README.markdown b/client/ayon_resolve/README.markdown index 3800eed7a7..4f58941260 100644 --- a/client/ayon_resolve/README.markdown +++ b/client/ayon_resolve/README.markdown @@ -1,7 +1,7 @@ ## Basic setup -- Actually supported version is up to v18 -- install Python 3.6.2 (latest tested v17) or up to 3.9.13 (latest tested on v18) +- Actually supported version is up to v19 +- install Python 3.6.2 (latest tested v17) or up to 3.9.13 (latest tested on v19) - pip install PySide2: - Python 3.9.*: open terminal and go to python.exe directory, then `python -m pip install PySide2` - pip install OpenTimelineIO: @@ -16,13 +16,14 @@ This is how it looks on my testing project timeline ![image](https://user-images.githubusercontent.com/40640033/102637638-96ec6600-4156-11eb-9656-6e8e3ce4baf8.png) -Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg confersion to jpg sequence. +Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg conversion to jpg sequence. 1. you need to start AYON menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__AYON_Menu__** 2. then select any clips in `main` track and change their color to `Chocolate` 3. in AYON Menu select `Create` -4. in Creator select `Create Publishable Clip [New]` (temporary name) +4. in Creator select `Create Publishable Clip` (temporary name) 5. set `Rename clips` to True, Master Track to `main` and Use review track to `review` as in picture - ![image](https://user-images.githubusercontent.com/40640033/102643773-0d419600-4160-11eb-919e-9c2be0aecab8.png) -6. after you hit `ok` all clips are colored to `ping` and marked with openpype metadata tag -7. git `Publish` on openpype menu and see that all had been collected correctly. That is the last step for now as rest is Work in progress. Next steps will follow. + ![image](./doc_examples.jpg) +6. after you hit `ok` all clips are colored to `pink` and marked with AYON metadata tag +7. on the right-menu column you'll see that all products have been collected correctly +8. hit `Publish` to trigger the publishing process \ No newline at end of file diff --git a/client/ayon_resolve/doc_examples.jpg b/client/ayon_resolve/doc_examples.jpg new file mode 100644 index 0000000000000000000000000000000000000000..631100c66c1cea22633db446f624bf68cf2a2303 GIT binary patch literal 155987 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

?5K6jFP)SZMVR4|0aAlcax1htA*C&RZ&)SU1 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 Date: Mon, 30 Sep 2024 12:45:41 -0400 Subject: [PATCH 41/58] Adjust typos and comments from PR feedback. --- client/ayon_resolve/api/lib.py | 1 - client/ayon_resolve/api/pipeline.py | 2 ++ client/ayon_resolve/api/plugin.py | 2 -- client/ayon_resolve/plugins/create/create_shot_clip.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 29056f7bce..0fc22bec6e 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -1080,7 +1080,6 @@ def export_timeline_otio_to_file(timeline, filepath): otio_export.write_to_file(otio_timeline, filepath) - def export_timeline_otio(timeline): """ Export timeline as otio. diff --git a/client/ayon_resolve/api/pipeline.py b/client/ayon_resolve/api/pipeline.py index 0ab80a81ba..61d10331ad 100644 --- a/client/ayon_resolve/api/pipeline.py +++ b/client/ayon_resolve/api/pipeline.py @@ -100,9 +100,11 @@ def get_containers(self): return ls() def get_context_data(self): + # TODO: implement to support persisting context attributes return {} def update_context_data(self, data, changes): + # TODO: implement to support persisting context attributes pass diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 5df58b7de5..be2816407d 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -134,8 +134,6 @@ def load(self, files): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - # create mediaItem in active project bin - # make sure files list is not empty and first available file exists filepath = next((f for f in files if os.path.isfile(f)), None) if not filepath: diff --git a/client/ayon_resolve/plugins/create/create_shot_clip.py b/client/ayon_resolve/plugins/create/create_shot_clip.py index 53f77eb26d..1a525177bc 100644 --- a/client/ayon_resolve/plugins/create/create_shot_clip.py +++ b/client/ayon_resolve/plugins/create/create_shot_clip.py @@ -386,7 +386,7 @@ def header_label(text): EnumDef( "clip_variant", label="Product Variant", - tooltip="Chose variant which will be then used for " + tooltip="Chosen variant which will be then used for " "product name, if " "is selected, name of track layer will be used", items=['', 'main', 'bg', 'fg', 'bg', 'animatic'], From 3594f9598bc638ade65a0a7957215703239ff9af Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 15:00:29 -0400 Subject: [PATCH 42/58] Restore previous clip load implementation --- client/ayon_resolve/api/lib.py | 24 ++++++++++-------------- client/ayon_resolve/api/plugin.py | 7 +------ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 0fc22bec6e..0643cee343 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -236,12 +236,12 @@ def remove_media_pool_item(media_pool_item: object) -> bool: return media_pool.DeleteClips([media_pool_item]) -def create_media_pool_item(fpath: str, +def create_media_pool_item(files: list, root: object = None) -> object: """ Create media pool item. Args: - fpath (str): absolute path to a file + files (list): absolute path to a file root (resolve.Folder)[optional]: root folder / bin object Returns: @@ -254,27 +254,23 @@ def create_media_pool_item(fpath: str, root_bin = root or media_pool.GetRootFolder() # try to search in bin if the clip does not exist - existing_mpi = get_media_pool_item(fpath, root_bin) + filepath = next((f for f in files if os.path.isfile(f)), None) + if not filepath: + raise FileNotFoundError("No file found in input files list") + + existing_mpi = get_media_pool_item(filepath, root_bin) if existing_mpi: return existing_mpi - dirname, file = os.path.split(fpath) - _name, ext = os.path.splitext(file) - - # add all data in folder to media-pool - media_pool_items = media_storage.AddItemListToMediaPool( - os.path.normpath(dirname)) + # add media to media-pool + media_pool_items = media_pool.ImportMedia(files) if not media_pool_items: return False - # if any are added then look into them for the right extension - media_pool_item = [mpi for mpi in media_pool_items - if ext in mpi.GetClipProperty("File Path")] - # return only first found - return media_pool_item.pop() + return media_pool_items.pop() def get_media_pool_item(filepath, root: object = None) -> object: diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index be2816407d..9c9e197d26 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -134,14 +134,9 @@ def load(self, files): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - # make sure files list is not empty and first available file exists - filepath = next((f for f in files if os.path.isfile(f)), None) - if not filepath: - raise FileNotFoundError("No file found in input files list") - # create clip media media_pool_item = lib.create_media_pool_item( - filepath, + files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty From 0e0c10c64bd6a6518e97e20dfe73ba1e42f91877 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 15:03:54 -0400 Subject: [PATCH 43/58] Fix linting. --- client/ayon_resolve/api/lib.py | 1 - client/ayon_resolve/api/plugin.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 0643cee343..7e006be467 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -248,7 +248,6 @@ def create_media_pool_item(files: list, object: resolve.MediaPoolItem """ # get all variables - media_storage = get_media_storage() resolve_project = get_current_resolve_project() media_pool = resolve_project.GetMediaPool() root_bin = root or media_pool.GetRootFolder() diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index 9c9e197d26..81845bc0b9 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -1,5 +1,4 @@ -import copy -import os +import copy import re import uuid From 0170d01882f4adbd2a8e17daa82c363c23c4c543 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 08:45:33 -0400 Subject: [PATCH 44/58] Adjust dependency to core addons. --- client/ayon_resolve/plugins/create/create_workfile.py | 4 ++-- package.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_workfile.py b/client/ayon_resolve/plugins/create/create_workfile.py index d6dfc6457c..56f713a2e1 100644 --- a/client/ayon_resolve/plugins/create/create_workfile.py +++ b/client/ayon_resolve/plugins/create/create_workfile.py @@ -49,8 +49,8 @@ def _create_new_instance(self): variant = self.default_variant project_name = self.create_context.get_current_project_name() host_name = self.create_context.host_name - folder_entity = self.get_current_folder_entity() - task_entity = self.get_current_task_entity() + folder_entity = self.create_context.get_current_folder_entity() + task_entity = self.create_context.get_current_task_entity() folder_path = folder_entity["path"] task_name = task_entity["name"] product_name = self.get_product_name( diff --git a/package.py b/package.py index 7eb32afdd4..acc9c6d09d 100644 --- a/package.py +++ b/package.py @@ -6,6 +6,6 @@ ayon_server_version = ">=1.1.2" ayon_required_addons = { - "core": ">0.3.2", + "core": ">=1.0.1", } ayon_compatible_addons = {} From 22f84c8d941197f2cc1fe45755b8a0cd3334074e Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 10:55:39 -0400 Subject: [PATCH 45/58] Adjust collect_editorial_package.py --- client/ayon_resolve/plugins/publish/collect_editorial_package.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index c79476c0aa..22ca39bc82 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -13,6 +13,7 @@ class EditorialPackageInstances(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.49 label = "Collect Editorial Package Instances" + families = ["editorial_pkg"] def process(self, context): project_name = context.data["projectName"] From 60ca5748c81506e9ce27af46bb716517f8644d0d Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 11:00:49 -0400 Subject: [PATCH 46/58] Fix linting. --- client/ayon_resolve/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index 2d551f0925..a196b0cb97 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -72,7 +72,7 @@ def maintain_current_timeline(to_timeline: object, >>> print(get_current_timeline().GetName()) timeline1 """ - resolve_project = get_current_resolve_project() + project = get_current_resolve_project() working_timeline = from_timeline or resolve_project.GetCurrentTimeline() # search timeline withing project timelines in case the From ac5d46d9472b215bb051b1d2dc61f06827fad34f Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 11:01:42 -0400 Subject: [PATCH 47/58] Fix linting. --- client/ayon_resolve/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_resolve/api/lib.py b/client/ayon_resolve/api/lib.py index a196b0cb97..63806db306 100644 --- a/client/ayon_resolve/api/lib.py +++ b/client/ayon_resolve/api/lib.py @@ -73,7 +73,7 @@ def maintain_current_timeline(to_timeline: object, timeline1 """ project = get_current_resolve_project() - working_timeline = from_timeline or resolve_project.GetCurrentTimeline() + working_timeline = from_timeline or project.GetCurrentTimeline() # search timeline withing project timelines in case the # to_timeline is MediaPoolItem From 4ee2ba0ac28d40dcc5d67cf6cb235b378f2b4baf Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 11:10:38 -0400 Subject: [PATCH 48/58] Adjust AYON_TAG_NAME. --- .../plugins/create/create_editorial_package.py | 4 ++-- .../ayon_resolve/plugins/load/load_editorial_package.py | 8 ++++---- .../plugins/publish/collect_editorial_package.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index fa473d5f91..e107c010d0 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -8,7 +8,7 @@ from ayon_api import get_folder_by_id, get_task_by_id, get_folder_by_path from ayon_core.pipeline.create.legacy_create import LegacyCreator -from ayon_resolve.api import lib +from ayon_resolve.api import lib, constants from ayon_resolve.api.plugin import get_editorial_publish_data @@ -71,7 +71,7 @@ def process(self): ) timeline_media_pool_item.SetMetadata( - lib.pype_tag_name, json.dumps(publish_data) + constants.AYON_TAG_NAME, json.dumps(publish_data) ) diff --git a/client/ayon_resolve/plugins/load/load_editorial_package.py b/client/ayon_resolve/plugins/load/load_editorial_package.py index 0cd54c4ea6..1215ca8d9b 100644 --- a/client/ayon_resolve/plugins/load/load_editorial_package.py +++ b/client/ayon_resolve/plugins/load/load_editorial_package.py @@ -8,7 +8,7 @@ get_representation_path, ) -from ayon_resolve.api import lib +from ayon_resolve.api import lib, constants from ayon_resolve.api.plugin import get_editorial_publish_data @@ -67,7 +67,7 @@ def load(self, context, name, namespace, data): context, data) timeline_media_pool_item.SetMetadata( - lib.pype_tag_name, json.dumps(clip_data) + constants.AYON_TAG_NAME, json.dumps(clip_data) ) # set clip color based on random choice @@ -86,7 +86,7 @@ def update(self, container, context): # Get the latest version of the container data timeline_media_pool_item = container["_item"] clip_data = timeline_media_pool_item.GetMetadata( - lib.pype_tag_name) + constants.AYON_TAG_NAME) clip_data = json.loads(clip_data) clip_data["load"] = {} @@ -96,7 +96,7 @@ def update(self, container, context): clip_data["publish"]["publish"] = False timeline_media_pool_item.SetMetadata( - lib.pype_tag_name, json.dumps(clip_data)) + constants.AYON_TAG_NAME, json.dumps(clip_data)) self.load( context, diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index 22ca39bc82..fe724a07b1 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -5,7 +5,7 @@ import ayon_api from ayon_api import get_task_by_id -from ayon_resolve.api import lib +from ayon_resolve.api import lib, constants class EditorialPackageInstances(pyblish.api.ContextPlugin): @@ -21,7 +21,7 @@ def process(self, context): for media_pool_item in lib.iter_all_media_pool_clips(): - data = media_pool_item.GetMetadata(lib.pype_tag_name) + data = media_pool_item.GetMetadata(constants.AYON_TAG_NAME) if not data: continue From c7109ff9e9a98b0e278020d5d00faff8506a9beb Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 15:05:44 -0400 Subject: [PATCH 49/58] Transfer create_editorial_package to new publisher. --- .../create/create_editorial_package.py | 146 +++++++++++------- .../publish/collect_editorial_package.py | 104 +++++-------- 2 files changed, 125 insertions(+), 125 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index e107c010d0..a6182c0dd8 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -1,88 +1,120 @@ import json from copy import deepcopy -from ayon_core.tools.context_dialog.window import ( - ContextDialog, - ContextDialogController -) -from ayon_core.pipeline import get_current_project_name -from ayon_api import get_folder_by_id, get_task_by_id, get_folder_by_path -from ayon_core.pipeline.create.legacy_create import LegacyCreator + +from ayon_core.pipeline.create import CreatorError, CreatedInstance from ayon_resolve.api import lib, constants -from ayon_resolve.api.plugin import get_editorial_publish_data +from ayon_resolve.api.plugin import ResolveCreator, get_editorial_publish_data -class CreateEditorialPackage(LegacyCreator): +class CreateEditorialPackage(ResolveCreator): """Create Editorial Package.""" - name = "editorial_pkg" + identifier = "io.ayon.creators.resolve.editorial_pkg" + product_name = "editorial_pkgMain" label = "Editorial Package" product_type = "editorial_pkg" icon = "camera" defaults = ["Main"] - def process(self): - """Process the creation of the editorial package.""" - project_name = get_current_project_name() - folder_path = self.data["folderPath"] - - current_folder = get_folder_by_path(project_name, folder_path) - print(current_folder) + def create(self, subset_name, instance_data, pre_create_data): + """ + """ + super(CreateEditorialPackage, self).create(subset_name, + instance_data, + pre_create_data) current_timeline = lib.get_current_timeline() - context = ask_for_context( - project_name, current_folder["id"] - ) - - if context is None: - return - - # Get workfile path to save to. - project_name = context["project_name"] - folder = get_folder_by_id(project_name, context["folder_id"]) - - # task is optional so we need to check if it is set - task = None - if "task_id" in context: - task = get_task_by_id(project_name, context["task_id"]) - - # reset self.data to be pointing in the set context data - self.data["folderPath"] = folder["path"] - - if task: - self.data.update({ - "taskId": task["id"], - "taskName": task["name"], - }) - if not current_timeline: - raise RuntimeError("Make sure to have an active current timeline.") + raise CreatorError("Make sure to have an active current timeline.") timeline_media_pool_item = lib.get_timeline_media_pool_item( current_timeline ) - publish_data = deepcopy(self.data) + publish_data = deepcopy(instance_data) + # add publish data for streamline publishing publish_data["publish"] = get_editorial_publish_data( - folder_path=folder["path"], - product_name=self.data["productName"], + folder_path=instance_data["folderPath"], + product_name=self.product_name, ) + publish_data["label"] = current_timeline.GetName() timeline_media_pool_item.SetMetadata( constants.AYON_TAG_NAME, json.dumps(publish_data) ) + publish_data["media_pool_item_id"] = timeline_media_pool_item.GetUniqueId() + new_instance = CreatedInstance( + self.product_type, + self.product_name, + publish_data, + self, + ) + new_instance.transient_data["timeline_item"] = timeline_media_pool_item + self._add_instance_to_context(new_instance) + + def collect_instances(self): + """Collect all created instances from current timeline.""" + for media_pool_item in lib.iter_all_media_pool_clips(): + data = media_pool_item.GetMetadata(constants.AYON_TAG_NAME) + if not data: + continue + + try: + data = json.loads(data) + except json.JSONDecodeError: + self.log.warning( + f"Failed to parse json data from media pool item: " + f"{media_pool_item.GetName()}" + ) + continue + + # exclude all which are not productType editorial_pkg + if ( + data.get("publish", {}).get("productType") != "editorial_pkg" + ): + continue + + data["media_pool_item_id"] = media_pool_item.GetUniqueId() + current_instance = CreatedInstance( + self.product_type, + self.product_name, + data, + self + ) + + current_instance.transient_data["timeline_item"] = media_pool_item + self._add_instance_to_context(current_instance) + + def update_instances(self, update_list): + """Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Gets list of tuples. Each item + contain changed instance and it's changes. + """ + for created_inst, _changes in update_list: + timeline_media_pool_item = created_inst.transient_data["timeline_item"] + timeline_media_pool_item.SetMetadata( + constants.AYON_TAG_NAME, + json.dumps(created_inst.data_to_store()), + ) + + def remove_instances(self, instances): + """Remove instance marker from track item. + + Args: + instance(List[CreatedInstance]): Instance objects which should be + removed. + """ + for instance in instances: + self._remove_instance_from_context(instance) + timeline_media_pool_item = instance.transient_data["timeline_item"] + timeline_media_pool_item.SetMetadata( + constants.AYON_TAG_NAME, + json.dumps({}), + ) -def ask_for_context( - project_name, folder_id -): - """Ask for context to create Editorial Package.""" - controller = ContextDialogController() - window = ContextDialog(controller=controller) - controller.set_expected_selection( - project_name, folder_id) - window.exec_() - - return controller.get_selected_context() diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index fe724a07b1..fb95c25f99 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -1,88 +1,56 @@ -import json - import pyblish.api import ayon_api -from ayon_api import get_task_by_id from ayon_resolve.api import lib, constants -class EditorialPackageInstances(pyblish.api.ContextPlugin): +class EditorialPackageInstances(pyblish.api.InstancePlugin): """Collect all Track items selection.""" order = pyblish.api.CollectorOrder - 0.49 label = "Collect Editorial Package Instances" families = ["editorial_pkg"] - def process(self, context): - project_name = context.data["projectName"] + def process(self, instance): + project_name = instance.context.data["projectName"] self.log.info(f"project: {project_name}") + media_pool_item_id = instance.data["media_pool_item_id"] for media_pool_item in lib.iter_all_media_pool_clips(): - - data = media_pool_item.GetMetadata(constants.AYON_TAG_NAME) - if not data: - continue - - try: - data = json.loads(data) - except json.JSONDecodeError: - self.log.warning( - f"Failed to parse json data from media pool item: " - f"{media_pool_item.GetName()}" - ) - continue - - # exclude all which are not productType editorial_pkg - if ( - data.get("publish") - and data["publish"].get("productType") != "editorial_pkg" - ): - continue - - instance = context.create_instance(name=media_pool_item.GetName()) - - publish_data = data["publish"] - - # get version from publish data and rise it one up - version = publish_data.get("version") - if version is not None: - version += 1 - - # make sure last version of product is higher than current - # expected current version from publish data - folder_entity = ayon_api.get_folder_by_path( - project_name=project_name, - folder_path=publish_data["folderPath"], - ) - last_version = ayon_api.get_last_version_by_product_name( - project_name=project_name, - product_name=publish_data["productName"], - folder_id=folder_entity["id"], - ) - if last_version is not None: - last_version = int(last_version["version"]) - if version <= last_version: - version = last_version + 1 - - publish_data["version"] = version - - publish_data.update( - { - "mediaPoolItem": media_pool_item, - "item": media_pool_item, - } + if media_pool_item.GetUniqueId() == media_pool_item_id: + break + else: + raise RuntimeError("Could not identify media pool item from instance.") + + # get version from publish data and rise it one up + version = instance.data.get("version") + if version is not None: + version += 1 + + # make sure last version of product is higher than current + # expected current version from publish data + folder_entity = ayon_api.get_folder_by_path( + project_name=project_name, + folder_path=publish_data["folderPath"], + ) + last_version = ayon_api.get_last_version_by_product_name( + project_name=project_name, + product_name=publish_data["productName"], + folder_id=folder_entity["id"], ) + if last_version is not None: + last_version = int(last_version["version"]) + if version <= last_version: + version = last_version + 1 - if publish_data.get("taskId"): - task_entity = get_task_by_id( - project_name=project_name, - task_id=publish_data["taskId"], - ) - publish_data["taskEntity"] = task_entity - publish_data["task"] = task_entity["name"] + instance.data["version"] = version - instance.data.update(publish_data) + instance.data.update( + { + "mediaPoolItem": media_pool_item, + "item": media_pool_item, + } + ) - self.log.info(f"Editorial Package: {instance.data}") + self.log.info(f"Editorial Package: {instance.data}") From 41266784141d88a5ee1f6bb4e542d4113c2d8b8e Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 7 Oct 2024 16:46:42 -0400 Subject: [PATCH 50/58] Apply suggestions from code review Co-authored-by: Roy Nieterau --- .../plugins/create/create_editorial_package.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index a6182c0dd8..aa8d5f654b 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -20,9 +20,9 @@ class CreateEditorialPackage(ResolveCreator): def create(self, subset_name, instance_data, pre_create_data): """ """ - super(CreateEditorialPackage, self).create(subset_name, - instance_data, - pre_create_data) + super().create(subset_name, + instance_data, + pre_create_data) current_timeline = lib.get_current_timeline() @@ -74,7 +74,7 @@ def collect_instances(self): # exclude all which are not productType editorial_pkg if ( - data.get("publish", {}).get("productType") != "editorial_pkg" + data.get("publish", {}).get("productType") != self.product_type ): continue From b0643a9bf8e4f4eeaf952938e7ad155a659b40cb Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 17:41:56 -0400 Subject: [PATCH 51/58] Address feedback from PR. --- .../create/create_editorial_package.py | 36 ++++++++++++------- .../publish/collect_editorial_package.py | 7 +--- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index aa8d5f654b..19e99b2553 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -11,14 +11,18 @@ class CreateEditorialPackage(ResolveCreator): """Create Editorial Package.""" identifier = "io.ayon.creators.resolve.editorial_pkg" - product_name = "editorial_pkgMain" label = "Editorial Package" product_type = "editorial_pkg" icon = "camera" defaults = ["Main"] def create(self, subset_name, instance_data, pre_create_data): - """ + """Create a new editorial_pkg instance. + + Args: + subset_name (str): The subset name + instance_data (dict): The instance data. + pre_create_data (dict): The pre_create context data. """ super().create(subset_name, instance_data, @@ -35,25 +39,32 @@ def create(self, subset_name, instance_data, pre_create_data): publish_data = deepcopy(instance_data) - # add publish data for streamline publishing + # add publish data for streamlrine publishing + product_name = self.get_product_name( + self.project_name, + self.create_context.get_current_folder_entity(), + self.create_context.get_current_task_entity(), + instance_data["variant"], + ) publish_data["publish"] = get_editorial_publish_data( folder_path=instance_data["folderPath"], - product_name=self.product_name, + product_name=product_name, ) - publish_data["label"] = current_timeline.GetName() + publish_data.update({ + "label": current_timeline.GetName(), + }) timeline_media_pool_item.SetMetadata( constants.AYON_TAG_NAME, json.dumps(publish_data) ) - publish_data["media_pool_item_id"] = timeline_media_pool_item.GetUniqueId() new_instance = CreatedInstance( self.product_type, - self.product_name, + publish_data["publish"]["productName"], publish_data, self, ) - new_instance.transient_data["timeline_item"] = timeline_media_pool_item + new_instance.transient_data["timeline_pool_item"] = timeline_media_pool_item self._add_instance_to_context(new_instance) def collect_instances(self): @@ -78,15 +89,14 @@ def collect_instances(self): ): continue - data["media_pool_item_id"] = media_pool_item.GetUniqueId() current_instance = CreatedInstance( self.product_type, - self.product_name, + data["publish"]["productName"], data, self ) - current_instance.transient_data["timeline_item"] = media_pool_item + current_instance.transient_data["timeline_pool_item"] = media_pool_item self._add_instance_to_context(current_instance) def update_instances(self, update_list): @@ -97,7 +107,7 @@ def update_instances(self, update_list): contain changed instance and it's changes. """ for created_inst, _changes in update_list: - timeline_media_pool_item = created_inst.transient_data["timeline_item"] + timeline_media_pool_item = created_inst.transient_data["timeline_pool_item"] timeline_media_pool_item.SetMetadata( constants.AYON_TAG_NAME, json.dumps(created_inst.data_to_store()), @@ -112,7 +122,7 @@ def remove_instances(self, instances): """ for instance in instances: self._remove_instance_from_context(instance) - timeline_media_pool_item = instance.transient_data["timeline_item"] + timeline_media_pool_item = instance.transient_data["timeline_pool_item"] timeline_media_pool_item.SetMetadata( constants.AYON_TAG_NAME, json.dumps({}), diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index fb95c25f99..dac4ac0b05 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -16,12 +16,7 @@ def process(self, instance): project_name = instance.context.data["projectName"] self.log.info(f"project: {project_name}") - media_pool_item_id = instance.data["media_pool_item_id"] - for media_pool_item in lib.iter_all_media_pool_clips(): - if media_pool_item.GetUniqueId() == media_pool_item_id: - break - else: - raise RuntimeError("Could not identify media pool item from instance.") + media_pool_item = instance.data["transientData"]["timeline_pool_item"] # get version from publish data and rise it one up version = instance.data.get("version") From de925affb797fb9eb434831a1894ff41653971da Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 7 Oct 2024 22:28:00 -0400 Subject: [PATCH 52/58] Apply suggestions from code review Co-authored-by: Roy Nieterau --- .../plugins/create/create_editorial_package.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index 19e99b2553..04b395b176 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -16,15 +16,15 @@ class CreateEditorialPackage(ResolveCreator): icon = "camera" defaults = ["Main"] - def create(self, subset_name, instance_data, pre_create_data): + def create(self, product_name, instance_data, pre_create_data): """Create a new editorial_pkg instance. Args: - subset_name (str): The subset name + product_name (str): The subset name instance_data (dict): The instance data. pre_create_data (dict): The pre_create context data. """ - super().create(subset_name, + super().create(product_name, instance_data, pre_create_data) @@ -39,13 +39,6 @@ def create(self, subset_name, instance_data, pre_create_data): publish_data = deepcopy(instance_data) - # add publish data for streamlrine publishing - product_name = self.get_product_name( - self.project_name, - self.create_context.get_current_folder_entity(), - self.create_context.get_current_task_entity(), - instance_data["variant"], - ) publish_data["publish"] = get_editorial_publish_data( folder_path=instance_data["folderPath"], product_name=product_name, From 5f6100eb873efd41e5e1bf4f479fc90cbbfb190c Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 7 Oct 2024 22:30:05 -0400 Subject: [PATCH 53/58] Fix feedback from PR. --- client/ayon_resolve/plugins/create/create_editorial_package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index 04b395b176..89bb362870 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -53,7 +53,7 @@ def create(self, product_name, instance_data, pre_create_data): new_instance = CreatedInstance( self.product_type, - publish_data["publish"]["productName"], + product_name, publish_data, self, ) From bfd9df5b7e93b7eb17f8d33573e7f7f9357c976c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Oct 2024 13:48:47 +0200 Subject: [PATCH 54/58] Improving loading and publish data distribution Update imports, add new function parameter 'task' to get_editorial_publish_data, refactor tag metadata creation in CreateEditorialPackage, handle backward compatibility for missing data fields in instances, and update metadata handling in LoadEditorialPackage and CreateEditorialPackage. --- client/ayon_resolve/api/plugin.py | 10 ++- .../create/create_editorial_package.py | 74 +++++++++++++------ .../plugins/load/load_editorial_package.py | 3 + .../publish/collect_editorial_package.py | 4 +- 4 files changed, 65 insertions(+), 26 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index bcb1bf9055..e239b80341 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -1,4 +1,4 @@ -import copy +import copy import re import uuid @@ -8,7 +8,7 @@ from ayon_core.pipeline import ( LoaderPlugin, Creator, - HiddenCreator, + HiddenCreator, Anatomy ) @@ -698,7 +698,8 @@ def remove_instances(self, instances): def get_editorial_publish_data( folder_path, product_name, - version=None + version=None, + task=None, ) -> dict: """Get editorial publish data from context. @@ -723,6 +724,9 @@ def get_editorial_publish_data( if version: data["version"] = version + if task: + data["task"] = task + return data diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index 89bb362870..21a9e23cb2 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -37,27 +37,29 @@ def create(self, product_name, instance_data, pre_create_data): current_timeline ) - publish_data = deepcopy(instance_data) - - publish_data["publish"] = get_editorial_publish_data( - folder_path=instance_data["folderPath"], - product_name=product_name, + tag_metadata = { + "publish": deepcopy(instance_data), + } + tag_metadata["publish"].update( + get_editorial_publish_data( + folder_path=instance_data["folderPath"], + product_name=product_name + ) ) + tag_metadata["publish"]["label"] = current_timeline.GetName() - publish_data.update({ - "label": current_timeline.GetName(), - }) timeline_media_pool_item.SetMetadata( - constants.AYON_TAG_NAME, json.dumps(publish_data) + constants.AYON_TAG_NAME, json.dumps(tag_metadata) ) new_instance = CreatedInstance( self.product_type, product_name, - publish_data, + tag_metadata["publish"], self, ) - new_instance.transient_data["timeline_pool_item"] = timeline_media_pool_item + new_instance.transient_data["timeline_pool_item"] = ( + timeline_media_pool_item) self._add_instance_to_context(new_instance) def collect_instances(self): @@ -82,14 +84,31 @@ def collect_instances(self): ): continue + publish_data = data["publish"] + + # TODO: backward compatibility for legacy workflow instances + # add label into instance data in case it is missing in publish + # data + if "label" not in publish_data: + publish_data["label"] = media_pool_item.GetName() + + # TODO: backward compatibility for legacy workflow instances + # add variant into instance data in case it is missing in publish + # data + if "variant" not in publish_data: + product_name = publish_data["productName"] + product_type = publish_data["productType"] + publish_data["variant"] = product_name.split(product_type)[1] + current_instance = CreatedInstance( self.product_type, - data["publish"]["productName"], - data, + publish_data["productName"], + publish_data, self ) - current_instance.transient_data["timeline_pool_item"] = media_pool_item + current_instance.transient_data["timeline_pool_item"] = ( + media_pool_item) self._add_instance_to_context(current_instance) def update_instances(self, update_list): @@ -99,11 +118,18 @@ def update_instances(self, update_list): update_list(List[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ + for created_inst, _changes in update_list: - timeline_media_pool_item = created_inst.transient_data["timeline_pool_item"] - timeline_media_pool_item.SetMetadata( + media_pool_item = created_inst.transient_data[ + "timeline_pool_item"] + data = media_pool_item.GetMetadata(constants.AYON_TAG_NAME) + data = json.loads(data) + + data["publish"].update(created_inst.data_to_store()) + + media_pool_item.SetMetadata( constants.AYON_TAG_NAME, - json.dumps(created_inst.data_to_store()), + json.dumps(data), ) def remove_instances(self, instances): @@ -115,9 +141,15 @@ def remove_instances(self, instances): """ for instance in instances: self._remove_instance_from_context(instance) - timeline_media_pool_item = instance.transient_data["timeline_pool_item"] - timeline_media_pool_item.SetMetadata( + media_pool_item = instance.transient_data["timeline_pool_item"] + + data = media_pool_item.GetMetadata(constants.AYON_TAG_NAME) + data = json.loads(data) + + # only removing publishing data since loading data has to remain + data["publish"] = {} + + media_pool_item.SetMetadata( constants.AYON_TAG_NAME, - json.dumps({}), + json.dumps(data), ) - diff --git a/client/ayon_resolve/plugins/load/load_editorial_package.py b/client/ayon_resolve/plugins/load/load_editorial_package.py index 1215ca8d9b..8fc0d1c44e 100644 --- a/client/ayon_resolve/plugins/load/load_editorial_package.py +++ b/client/ayon_resolve/plugins/load/load_editorial_package.py @@ -112,6 +112,7 @@ def _get_container_data( ) -> dict: """Return metadata related to the representation and version.""" + representation = context["representation"] # add additional metadata from the version to imprint AYON knob version_entity = context["version"] @@ -144,6 +145,8 @@ def _get_container_data( folder_path=context["folder"]["path"], product_name=context["product"]["name"], version=version_entity["version"], + task=context["representation"]["context"].get("task", {}).get( + "name"), ) return data diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index dac4ac0b05..a84e34e76e 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -27,11 +27,11 @@ def process(self, instance): # expected current version from publish data folder_entity = ayon_api.get_folder_by_path( project_name=project_name, - folder_path=publish_data["folderPath"], + folder_path=instance.data["folderPath"], ) last_version = ayon_api.get_last_version_by_product_name( project_name=project_name, - product_name=publish_data["productName"], + product_name=instance.data["productName"], folder_id=folder_entity["id"], ) if last_version is not None: From 60cef7d5dcbaa4c1442f8cd713c1b326a284c745 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Tue, 8 Oct 2024 08:15:25 -0400 Subject: [PATCH 55/58] Apply suggestions from code review Co-authored-by: Roy Nieterau --- client/ayon_resolve/plugins/create/create_editorial_package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index 21a9e23cb2..56125f8b59 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -20,7 +20,7 @@ def create(self, product_name, instance_data, pre_create_data): """Create a new editorial_pkg instance. Args: - product_name (str): The subset name + product_name (str): The product name instance_data (dict): The instance data. pre_create_data (dict): The pre_create context data. """ From 01bad38a167d8349f7048aa86ab6c1853fd8a0f3 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 8 Oct 2024 10:01:02 -0400 Subject: [PATCH 56/58] Adjust feedback from PR. --- client/ayon_resolve/api/plugin.py | 1 + .../ayon_resolve/plugins/create/create_editorial_package.py | 4 ++-- .../ayon_resolve/plugins/publish/collect_editorial_package.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_resolve/api/plugin.py b/client/ayon_resolve/api/plugin.py index e239b80341..95fcb85bf1 100644 --- a/client/ayon_resolve/api/plugin.py +++ b/client/ayon_resolve/api/plugin.py @@ -707,6 +707,7 @@ def get_editorial_publish_data( folder_path (str): Folder path where editorial package is located. product_name (str): Editorial product name. version (Optional[str]): Editorial product version. Defaults to None. + task (Optional[str]): Associated task name. Defaults to None (no task). Returns: dict: Editorial publish data. diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index 56125f8b59..a40f98e797 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -73,8 +73,8 @@ def collect_instances(self): data = json.loads(data) except json.JSONDecodeError: self.log.warning( - f"Failed to parse json data from media pool item: " - f"{media_pool_item.GetName()}" + "Failed to parse json data from media pool item: %s", + media_pool_item.GetName() ) continue diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index a84e34e76e..cd766e91de 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -48,4 +48,4 @@ def process(self, instance): } ) - self.log.info(f"Editorial Package: {instance.data}") + self.log.debug(f"Editorial Package: {instance.data}") From b38150e6eae8e10e1d8d31cbb6abe0e040b642be Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 8 Oct 2024 10:43:58 -0400 Subject: [PATCH 57/58] Address feedback from PR. --- .../plugins/create/create_editorial_package.py | 6 ++---- .../plugins/publish/collect_editorial_package.py | 7 ++++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/client/ayon_resolve/plugins/create/create_editorial_package.py b/client/ayon_resolve/plugins/create/create_editorial_package.py index a40f98e797..1c33c6754e 100644 --- a/client/ayon_resolve/plugins/create/create_editorial_package.py +++ b/client/ayon_resolve/plugins/create/create_editorial_package.py @@ -86,11 +86,9 @@ def collect_instances(self): publish_data = data["publish"] - # TODO: backward compatibility for legacy workflow instances # add label into instance data in case it is missing in publish - # data - if "label" not in publish_data: - publish_data["label"] = media_pool_item.GetName() + # data (legacy publish) or timeline was renamed. + publish_data["label"] = media_pool_item.GetName() # TODO: backward compatibility for legacy workflow instances # add variant into instance data in case it is missing in publish diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index cd766e91de..0f1b6a1695 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -18,9 +18,14 @@ def process(self, instance): media_pool_item = instance.data["transientData"]["timeline_pool_item"] - # get version from publish data and rise it one up + # Special case for versioning editorial_pkg products: + # * instance created by creator: version up as usual + # * instance created from loader: loaded version is added + # into the instance by loader. Then version up from initial pkg + # to keep 'chained' version across tasks/product when possible. version = instance.data.get("version") if version is not None: + # get version from publish data and rise it one up version += 1 # make sure last version of product is higher than current From c020d1f87c54855c24933a590c4dacf542ff2fbb Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 8 Oct 2024 11:04:34 -0400 Subject: [PATCH 58/58] Fix linting. --- client/ayon_resolve/plugins/load/load_editorial_package.py | 1 - .../ayon_resolve/plugins/publish/collect_editorial_package.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/client/ayon_resolve/plugins/load/load_editorial_package.py b/client/ayon_resolve/plugins/load/load_editorial_package.py index 8fc0d1c44e..f55b9fb54d 100644 --- a/client/ayon_resolve/plugins/load/load_editorial_package.py +++ b/client/ayon_resolve/plugins/load/load_editorial_package.py @@ -112,7 +112,6 @@ def _get_container_data( ) -> dict: """Return metadata related to the representation and version.""" - representation = context["representation"] # add additional metadata from the version to imprint AYON knob version_entity = context["version"] diff --git a/client/ayon_resolve/plugins/publish/collect_editorial_package.py b/client/ayon_resolve/plugins/publish/collect_editorial_package.py index 0f1b6a1695..5dbed13658 100644 --- a/client/ayon_resolve/plugins/publish/collect_editorial_package.py +++ b/client/ayon_resolve/plugins/publish/collect_editorial_package.py @@ -2,8 +2,6 @@ import ayon_api -from ayon_resolve.api import lib, constants - class EditorialPackageInstances(pyblish.api.InstancePlugin): """Collect all Track items selection."""