From ca6f2b467ded88c0b0a8ac662f1e75145394f0a3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 24 Apr 2023 11:40:33 +0200 Subject: [PATCH 001/128] Add Fusion USD loader --- .../hosts/fusion/plugins/load/load_usd.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 openpype/hosts/fusion/plugins/load/load_usd.py diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py new file mode 100644 index 0000000000..8c2c69f52f --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -0,0 +1,73 @@ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) + + +class FusionLoadAlembicMesh(load.LoaderPlugin): + """Load USD into Fusion + + Support for USD was added since Fusion 18.5 + """ + + families = ["*"] + representations = ["*"] + extensions = {"usd", "usda", "usdz"} + + label = "Load USD" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "uLoader" + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["Filename"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["Filename"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() From bd049da6f93fdffac69d673a66d9387351471576 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 20 Sep 2023 16:11:13 +0200 Subject: [PATCH 002/128] Update openpype/hosts/fusion/plugins/load/load_usd.py --- openpype/hosts/fusion/plugins/load/load_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index 8c2c69f52f..f12fbd5ed0 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -9,7 +9,7 @@ from openpype.hosts.fusion.api import ( ) -class FusionLoadAlembicMesh(load.LoaderPlugin): +class FusionLoadUSD(load.LoaderPlugin): """Load USD into Fusion Support for USD was added since Fusion 18.5 From 759dc59132bc32ce07b6f204c8da7df8ae9f2715 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Sep 2023 17:33:27 +0200 Subject: [PATCH 003/128] add clip to timeline in correct place --- openpype/hosts/resolve/api/lib.py | 9 ++++++++- openpype/hosts/resolve/api/plugin.py | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index eaee3bb9ba..4e8b3a4107 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -246,7 +246,8 @@ def get_media_pool_item(fpath, root: object = None) -> object: def create_timeline_item(media_pool_item: object, timeline: object = None, source_start: int = None, - source_end: int = None) -> object: + source_end: int = None, + timeline_in: int = None) -> object: """ Add media pool item to current or defined timeline. @@ -278,6 +279,12 @@ def create_timeline_item(media_pool_item: object, clip_data.update({"endFrame": source_end}) print(clip_data) + + if timeline_in: + timeline_start = timeline.GetStartFrame() + # Create a clipInfo dictionary with the necessary information + clip_data["recordFrame"] = int(timeline_start + timeline_in) + # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index e2bd76ffa2..c679aa062d 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -402,6 +402,9 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) + self.timeline_in = int(self.data["assetData"]["clipIn"]) + + source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -416,7 +419,12 @@ class ClipLoader: # make track item from source in bin as item timeline_item = lib.create_timeline_item( - media_pool_item, self.active_timeline, source_in, source_out) + media_pool_item, + self.active_timeline, + source_in, + source_out, + self.timeline_in + ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item From 7cd8be0afa68005bc615523fd4a0ee55f73e1a30 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 11:49:51 +0200 Subject: [PATCH 004/128] resolve: add option for adding clips sequentially - or to asset define place - also create track with a name --- openpype/hosts/resolve/api/lib.py | 12 +++--- openpype/hosts/resolve/api/plugin.py | 55 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 4e8b3a4107..8f7eba8a90 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -274,17 +274,15 @@ def create_timeline_item(media_pool_item: object, # add source time range if input was given if source_start is not None: - clip_data.update({"startFrame": source_start}) + clip_data["startFrame"] = source_start if source_end is not None: - clip_data.update({"endFrame": source_end}) + clip_data["endFrame"] = source_end + + # Create a clipInfo dictionary with the necessary information + clip_data["recordFrame"] = timeline_in print(clip_data) - if timeline_in: - timeline_start = timeline.GetStartFrame() - # Create a clipInfo dictionary with the necessary information - clip_data["recordFrame"] = int(timeline_start + timeline_in) - # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index c679aa062d..b1bde212fe 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -312,6 +312,9 @@ class ClipLoader: # try to get value from options or evaluate key value for `load_to` self.new_timeline = options.get("newTimeline") or bool( "New timeline" in options.get("load_to", "")) + # try to get value from options or evaluate key value for `load_how` + self.sequential_load = options.get("sequentially") or bool( + "Sequentially in order" in options.get("load_how", "")) assert self._populate_data(), str( "Cannot Load selected data, look into database " @@ -352,6 +355,7 @@ class ClipLoader: asset = str(repr_cntx["asset"]) subset = str(repr_cntx["subset"]) representation = str(repr_cntx["representation"]) + self.data["track_name"] = "{}_{}".format(asset, representation) self.data["clip_name"] = "_".join([asset, subset, representation]) self.data["versionData"] = self.context["version"]["data"] # gets file path @@ -383,6 +387,33 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] + def _set_active_track(self): + """ Set active track to `track` """ + track_type = "video" + track_name = self.data["track_name"] + track_exists = False + + # get total track count + track_count = self.active_timeline.GetTrackCount(track_type) + # loop all tracks by track indexes + for track_index in range(1, int(track_count) + 1): + # get current track name + _track_name = self.active_timeline.GetTrackName( + track_type, track_index) + if track_name != _track_name: + continue + track_exists = True + break + + if not track_exists: + self.active_timeline.AddTrack(track_type) + self.active_timeline.SetTrackName( + track_type, + track_index + 1, + track_name + ) + + def load(self): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) @@ -402,8 +433,18 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) - self.timeline_in = int(self.data["assetData"]["clipIn"]) + # handle timeline tracks + self._set_active_track() + # get timeline in + timeline_start = self.active_timeline.GetStartFrame() + if self.sequential_load: + # set timeline start frame + timeline_in = int(timeline_start) + else: + # set timeline start frame + original clip in frame + timeline_in = int( + timeline_start + self.data["assetData"]["clipIn"]) source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -423,7 +464,7 @@ class ClipLoader: self.active_timeline, source_in, source_out, - self.timeline_in + timeline_in ) print("Loading clips: `{}`".format(self.data["clip_name"])) @@ -478,6 +519,16 @@ class TimelineItemLoader(LoaderPlugin): ], default=0, help="Where do you want clips to be loaded?" + ), + qargparse.Choice( + "load_how", + label="How to load clips", + items=[ + "Original timing", + "Sequentially in order" + ], + default="Original timing", + help="Would you like to place it at original timing?" ) ] From ec893d45e3e47ac95ec6f9d3ba16513c70009a78 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 11:50:10 +0200 Subject: [PATCH 005/128] updating docs readme to latest python api --- ...0.4.txt => RESOLVE_API_v18.5.1-build6.txt} | 81 +++++++++++++++---- 1 file changed, 65 insertions(+), 16 deletions(-) rename openpype/hosts/resolve/{RESOLVE_API_v18.0.4.txt => RESOLVE_API_v18.5.1-build6.txt} (89%) diff --git a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt similarity index 89% rename from openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt rename to openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt index 98597a12cb..7d1d6edf61 100644 --- a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt +++ b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt @@ -1,4 +1,4 @@ -Updated as of 9 May 2022 +Updated as of 26 May 2023 ---------------------------- 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. @@ -19,7 +19,7 @@ DaVinci Resolve scripting requires one of the following to be installed (for all Lua 5.1 Python 2.7 64-bit - Python 3.6 64-bit + Python >= 3.6 64-bit Using a script @@ -171,6 +171,10 @@ Project GetRenderResolutions(format, codec) --> [{Resolution}] # Returns list of resolutions applicable for the given render format (string) and render codec (string). Returns full list of resolutions if no argument is provided. Each element in the list is a dictionary with 2 keys "Width" and "Height". RefreshLUTList() --> Bool # Refreshes LUT List GetUniqueId() --> string # Returns a unique ID for the project item + InsertAudioToCurrentTrackAtPlayhead(mediaPath, --> Bool # Inserts the media specified by mediaPath (string) with startOffsetInSamples (int) and durationInSamples (int) at the playhead on a selected track on the Fairlight page. Returns True if successful, otherwise False. + startOffsetInSamples, durationInSamples) + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for project when supplied presetName (string). Returns true if successful. + ExportCurrentFrameAsStill(filePath) --> Bool # Exports current frame as still to supplied filePath. filePath must end in valid export file format. Returns True if succssful, False otherwise. MediaStorage GetMountedVolumeList() --> [paths...] # Returns list of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. @@ -179,6 +183,7 @@ MediaStorage RevealInStorage(path) --> Bool # Expands and displays given file/folder path in Resolve’s Media Storage. AddItemListToMediaPool(item1, item2, ...) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([items...]) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. + AddItemListToMediaPool([{itemInfo}, ...]) --> [clips...] # Adds list of itemInfos specified as dict of "media", "startFrame" (int), "endFrame" (int) from Media Storage into current Media Pool folder. Returns a list of the MediaPoolItems created. AddClipMattesToMediaPool(MediaPoolItem, [paths], stereoEye) --> Bool # Adds specified media files as mattes for the specified MediaPoolItem. StereoEye is an optional argument for specifying which eye to add the matte to for stereo clips ("left" or "right"). Returns True if successful. AddTimelineMattesToMediaPool([paths]) --> [MediaPoolItems] # Adds specified media files as timeline mattes in current media pool folder. Returns a list of created MediaPoolItems. @@ -189,20 +194,22 @@ MediaPool CreateEmptyTimeline(name) --> Timeline # Adds new timeline with given name. AppendToTimeline(clip1, clip2, ...) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([clips]) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. - AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only). Returns the list of appended timelineItems. + AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only), "trackIndex" (int) and "recordFrame" (int). Returns the list of appended timelineItems. CreateTimelineFromClips(name, clip1, clip2,...) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [clips]) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. - CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int). - ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file and optional importOptions dict, with support for the keys: - # "timelineName": string, specifies the name of the timeline to be created - # "importSourceClips": Bool, specifies whether source clips should be imported, True by default + CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), "recordFrame" (int). + ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file (AAF/EDL/XML/FCPXML/DRT/ADL) and optional importOptions dict, with support for the keys: + # "timelineName": string, specifies the name of the timeline to be created. Not valid for DRT import + # "importSourceClips": Bool, specifies whether source clips should be imported, True by default. Not valid for DRT import # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "importSourceClips" is True - # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False + # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False. Not valid for DRT import # "interlaceProcessing": Bool, specifies whether to enable interlace processing on the imported timeline being created. valid only for AAF import DeleteTimelines([timeline]) --> Bool # Deletes specified timelines in the media pool. GetCurrentFolder() --> Folder # Returns currently selected Folder. SetCurrentFolder(Folder) --> Bool # Sets current folder by given Folder. DeleteClips([clips]) --> Bool # Deletes specified clips or timeline mattes in the media pool + ImportFolderFromFile(filePath, sourceClipsPath="") --> Bool # Returns true if import from given DRB filePath is successful, false otherwise + # sourceClipsPath is a string that specifies a filesystem path to search for source clips if the media is inaccessible in their original path, empty by default DeleteFolders([subfolders]) --> Bool # Deletes specified subfolders in the media pool MoveClips([clips], targetFolder) --> Bool # Moves specified clips to target folder. MoveFolders([folders], targetFolder) --> Bool # Moves specified folders to target folder. @@ -225,6 +232,7 @@ Folder GetSubFolderList() --> [folders...] # Returns a list of subfolders in the folder. GetIsFolderStale() --> bool # Returns true if folder is stale in collaboration mode, false otherwise GetUniqueId() --> string # Returns a unique ID for the media pool folder + Export(filePath) --> bool # Returns true if export of DRB folder to filePath is successful, false otherwise MediaPoolItem GetName() --> string # Returns the clip name. @@ -257,6 +265,8 @@ MediaPoolItem UnlinkProxyMedia() --> Bool # Unlinks any proxy media associated with clip. ReplaceClip(filePath) --> Bool # Replaces the underlying asset and metadata of MediaPoolItem with the specified absolute clip path. GetUniqueId() --> string # Returns a unique ID for the media pool item + TranscribeAudio() --> Bool # Transcribes audio of the MediaPoolItem. Returns True if successful; False otherwise + ClearTranscription() --> Bool # Clears audio transcription of the MediaPoolItem. Returns True if successful; False otherwise. Timeline GetName() --> string # Returns the timeline name. @@ -266,6 +276,23 @@ Timeline SetStartTimecode(timecode) --> Bool # Set the start timecode of the timeline to the string 'timecode'. Returns true when the change is successful, false otherwise. GetStartTimecode() --> string # Returns the start timecode for the timeline. GetTrackCount(trackType) --> int # Returns the number of tracks for the given track type ("audio", "video" or "subtitle"). + AddTrack(trackType, optionalSubTrackType) --> Bool # Adds track of trackType ("video", "subtitle", "audio"). Second argument optionalSubTrackType is required for "audio" + # optionalSubTrackType can be one of {"mono", "stereo", "5.1", "5.1film", "7.1", "7.1film", "adaptive1", ... , "adaptive24"} + DeleteTrack(trackType, trackIndex) --> Bool # Deletes track of trackType ("video", "subtitle", "audio") and given trackIndex. 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackEnable(trackType, trackIndex, Bool) --> Bool # Enables/Disables track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackEnabled(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is enabled and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackLock(trackType, trackIndex, Bool) --> Bool # Locks/Unlocks track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackLocked(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is locked and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + DeleteClips([timelineItems], Bool) --> Bool # Deletes specified TimelineItems from the timeline, performing ripple delete if the second argument is True. Second argument is optional (The default for this is False) + SetClipsLinked([timelineItems], Bool) --> Bool # Links or unlinks the specified TimelineItems depending on second argument. GetItemListInTrack(trackType, index) --> [items...] # Returns a list of timeline items on that track (based on trackType and index). 1 <= index <= GetTrackCount(trackType). AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) @@ -301,7 +328,7 @@ Timeline # "sourceClipsFolders": string, list of Media Pool folder objects to search for source clips if the media is not present in current folder Export(fileName, exportType, exportSubtype) --> Bool # Exports timeline to 'fileName' as per input exportType & exportSubtype format. - # Refer to section "Looking up timeline exports properties" for information on the parameters. + # Refer to section "Looking up timeline export properties" for information on the parameters. GetSetting(settingName) --> string # Returns value of timeline setting (indicated by settingName : string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets timeline setting (indicated by settingName : string) to the value (settingValue : string). Check the section below for more information. InsertGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a generator (indicated by generatorName : string) into the timeline. @@ -313,6 +340,8 @@ Timeline GrabStill() --> galleryStill # Grabs still from the current video clip. Returns a GalleryStill object. GrabAllStills(stillFrameSource) --> [galleryStill] # Grabs stills from all the clips of the timeline at 'stillFrameSource' (1 - First frame, 2 - Middle frame). Returns the list of GalleryStill objects. GetUniqueId() --> string # Returns a unique ID for the timeline + CreateSubtitlesFromAudio() --> Bool # Creates subtitles from audio for the timeline. Returns True on success, False otherwise. + DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. TimelineItem GetName() --> string # Returns the item name. @@ -362,6 +391,7 @@ TimelineItem GetStereoLeftFloatingWindowParams() --> {keyframes...} # For the LEFT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetStereoRightFloatingWindowParams() --> {keyframes...} # For the RIGHT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetNumNodes() --> int # Returns the number of nodes in the current graph for the timeline item + ApplyArriCdlLut() --> Bool # Applies ARRI CDL and LUT. Returns True if successful, False otherwise. SetLUT(nodeIndex, lutPath) --> Bool # Sets LUT on the node mapping the node index provided, 1 <= nodeIndex <= total number of nodes. # The lutPath can be an absolute path, or a relative path (based off custom LUT paths or the master LUT path). # The operation is successful for valid lut paths that Resolve has already discovered (see Project.RefreshLUTList). @@ -376,8 +406,16 @@ TimelineItem SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. + SetClipEnabled(Bool) --> Bool # Sets clip enabled based on argument. + GetClipEnabled() --> Bool # Gets clip enabled status. UpdateSidecar() --> Bool # Updates sidecar file for BRAW clips or RMD file for R3D clips. GetUniqueId() --> string # Returns a unique ID for the timeline item + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for clip when supplied presetName (string). Returns true if successful. + GetNodeLabel(nodeIndex) --> string # Returns the label of the node at nodeIndex. + CreateMagicMask(mode) --> Bool # Returns True if magic mask was created successfully, False otherwise. mode can "F" (forward), "B" (backward), or "BI" (bidirection) + RegenerateMagicMask() --> Bool # Returns True if magic mask was regenerated successfully, False otherwise. + Stabilize() --> Bool # Returns True if stabilization was successful, False otherwise + SmartReframe() --> Bool # Performs Smart Reframe. Returns True if successful, False otherwise. Gallery GetAlbumName(galleryStillAlbum) --> string # Returns the name of the GalleryStillAlbum object 'galleryStillAlbum'. @@ -422,9 +460,11 @@ Invoke "Project:SetSetting", "Timeline:SetSetting" or "MediaPoolItem:SetClipProp ensure the success of the operation. You can troubleshoot the validity of keys and values by setting the desired result from the UI and checking property snapshots before and after the change. The following Project properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 0 and 3 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"superScale" - the property value is an enumerated integer between 0 and 4 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = Project:GetSetting('superScale') and Project:SetSetting('superScale', x) +• for '2x Enhanced' --> Project:SetSetting('superScale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] "timelineFrameRate" - the property value is one of the frame rates available to the user in project settings under "Timeline frame rate" option. Drop Frame can be configured for supported frame rates by appending the frame rate with "DF", e.g. "29.97 DF" will enable drop frame and "29.97" will disable drop frame @@ -432,9 +472,11 @@ Affects: • x = Project:GetSetting('timelineFrameRate') and Project:SetSetting('timelineFrameRate', x) The following Clip properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 1 and 3 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"Super Scale" - the property value is an enumerated integer between 1 and 4 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = MediaPoolItem:GetClipProperty('Super Scale') and MediaPoolItem:SetClipProperty('Super Scale', x) +• for '2x Enhanced' --> MediaPoolItem:SetClipProperty('Super Scale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] Looking up Render Settings @@ -478,11 +520,6 @@ exportType can be one of the following constants: - resolve.EXPORT_DRT - resolve.EXPORT_EDL - resolve.EXPORT_FCP_7_XML - - resolve.EXPORT_FCPXML_1_3 - - resolve.EXPORT_FCPXML_1_4 - - resolve.EXPORT_FCPXML_1_5 - - resolve.EXPORT_FCPXML_1_6 - - resolve.EXPORT_FCPXML_1_7 - resolve.EXPORT_FCPXML_1_8 - resolve.EXPORT_FCPXML_1_9 - resolve.EXPORT_FCPXML_1_10 @@ -492,6 +529,8 @@ exportType can be one of the following constants: - resolve.EXPORT_TEXT_TAB - resolve.EXPORT_DOLBY_VISION_VER_2_9 - resolve.EXPORT_DOLBY_VISION_VER_4_0 + - resolve.EXPORT_DOLBY_VISION_VER_5_1 + - resolve.EXPORT_OTIO exportSubtype can be one of the following enums: - resolve.EXPORT_NONE - resolve.EXPORT_AAF_NEW @@ -504,6 +543,16 @@ When exportType is resolve.EXPORT_AAF, valid exportSubtype values are resolve.EX When exportType is resolve.EXPORT_EDL, valid exportSubtype values are resolve.EXPORT_CDL, resolve.EXPORT_SDL, resolve.EXPORT_MISSING_CLIPS and resolve.EXPORT_NONE. Note: Replace 'resolve.' when using the constants above, if a different Resolve class instance name is used. +Unsupported exportType types +--------------------------------- +Starting with DaVinci Resolve 18.1, the following export types are not supported: + - resolve.EXPORT_FCPXML_1_3 + - resolve.EXPORT_FCPXML_1_4 + - resolve.EXPORT_FCPXML_1_5 + - resolve.EXPORT_FCPXML_1_6 + - resolve.EXPORT_FCPXML_1_7 + + Looking up Timeline item properties ----------------------------------- This section covers additional notes for the function "TimelineItem:SetProperty" and "TimelineItem:GetProperty". These functions are used to get and set properties mentioned. From fac33119ec0e7a7087b053b95711b85dd13dc791 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 13:28:57 +0200 Subject: [PATCH 006/128] Enable only in Fusion 18.5+ --- openpype/hosts/fusion/plugins/load/load_usd.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index f12fbd5ed0..beab0c8ecf 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -7,6 +7,7 @@ from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) +from openpype.hosts.fusion.api.lib import get_fusion_module class FusionLoadUSD(load.LoaderPlugin): @@ -26,6 +27,19 @@ class FusionLoadUSD(load.LoaderPlugin): tool_type = "uLoader" + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(FusionLoadUSD, cls).apply_settings(project_settings, + system_settings) + if cls.enabled: + # Enable only in Fusion 18.5+ + fusion = get_fusion_module() + version = fusion.GetVersion() + major = version[1] + minor = version[2] + is_usd_supported = (major, minor) >= (18, 5) + cls.enabled = is_usd_supported + def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: From 277a0baa7bbe89b2a58fbe9568271e4d5f72e86b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 13:29:49 +0200 Subject: [PATCH 007/128] Hound --- openpype/hosts/fusion/plugins/load/load_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index beab0c8ecf..4f1813a646 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -29,7 +29,7 @@ class FusionLoadUSD(load.LoaderPlugin): @classmethod def apply_settings(cls, project_settings, system_settings): - super(FusionLoadUSD, cls).apply_settings(project_settings, + super(FusionLoadUSD, cls).apply_settings(project_settings, system_settings) if cls.enabled: # Enable only in Fusion 18.5+ From 8c508b4b00082e7f0427fa929a5d2e0d8be19d99 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 15:45:38 +0200 Subject: [PATCH 008/128] removing testing scripts --- .../utility_scripts/tests/test_otio_as_edl.py | 49 ------------- .../testing_create_timeline_item_from_path.py | 73 ------------------- .../tests/testing_load_media_pool_item.py | 24 ------ .../tests/testing_startup_script.py | 5 -- .../tests/testing_timeline_op.py | 13 ---- 5 files changed, 164 deletions(-) delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py diff --git a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py b/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py deleted file mode 100644 index 92f2e43a72..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py +++ /dev/null @@ -1,49 +0,0 @@ -#! python3 -import os -import sys - -import opentimelineio as otio - -from openpype.pipeline import install_host - -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.testing_utils import TestGUI -from openpype.hosts.resolve.otio import davinci_export as otio_export - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - project = bmdvr.get_current_project() - otio_timeline = otio_export.create_otio_timeline(project) - print(f"_ otio_timeline: `{otio_timeline}`") - edl_path = os.path.join(self.input_dir_path, "this_file_name.edl") - print(f"_ edl_path: `{edl_path}`") - # xml_string = otio_adapters.fcpx_xml.write_to_string(otio_timeline) - # print(f"_ xml_string: `{xml_string}`") - otio.adapters.write_to_file( - otio_timeline, edl_path, adapter_name="cmx_3600") - project = bmdvr.get_current_project() - media_pool = project.GetMediaPool() - timeline = media_pool.ImportTimelineFromFile(edl_path) - # at the end close the window - self._close_window(None) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py b/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py deleted file mode 100644 index 91a361ec08..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py +++ /dev/null @@ -1,73 +0,0 @@ -#! python3 -import os -import sys - -import clique - -from openpype.pipeline import install_host -from openpype.hosts.resolve.api.testing_utils import TestGUI -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - - self.dir_processing(self.input_dir_path) - - # at the end close the window - self._close_window(None) - - def dir_processing(self, dir_path): - collections, reminders = clique.assemble(os.listdir(dir_path)) - - # process reminders - for _rem in reminders: - _rem_path = os.path.join(dir_path, _rem) - - # go deeper if directory - if os.path.isdir(_rem_path): - print(_rem_path) - self.dir_processing(_rem_path) - else: - self.file_processing(_rem_path) - - # process collections - for _coll in collections: - _coll_path = os.path.join(dir_path, list(_coll).pop()) - self.file_processing(_coll_path) - - def file_processing(self, fpath): - print(f"_ fpath: `{fpath}`") - _base, ext = os.path.splitext(fpath) - # skip if unwanted extension - if ext not in self.extensions: - return - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py b/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py deleted file mode 100644 index 2e83188bde..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py +++ /dev/null @@ -1,24 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -def file_processing(fpath): - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - path = "C:/CODE/__openpype_projects/jtest03dev/shots/sq01/mainsq01sh030/publish/plate/plateMain/v006/jt3d_mainsq01sh030_plateMain_v006.0996.exr" - - # activate resolve from openpype - install_host(bmdvr) - - file_processing(path) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py b/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py deleted file mode 100644 index b64714ab16..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py +++ /dev/null @@ -1,5 +0,0 @@ -#! python3 -from openpype.hosts.resolve.startup import main - -if __name__ == "__main__": - main() diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py b/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py deleted file mode 100644 index 8270496f64..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py +++ /dev/null @@ -1,13 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import get_current_project - -if __name__ == "__main__": - install_host(bmdvr) - project = get_current_project() - timeline_count = project.GetTimelineCount() - print(f"Timeline count: {timeline_count}") - timeline = project.GetTimelineByIndex(timeline_count) - print(f"Timeline name: {timeline.GetName()}") - print(timeline.GetTrackCount("video")) From 88a1f97ad595000b6a440643b61cbcd3b8f659c9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 15:48:27 +0200 Subject: [PATCH 009/128] resolve: improving loading --- openpype/hosts/resolve/api/lib.py | 54 +++++++++++++++++----------- openpype/hosts/resolve/api/plugin.py | 43 ++++------------------ 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 8f7eba8a90..22be929412 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -6,7 +6,10 @@ import contextlib from opentimelineio import opentime from openpype.lib import Logger -from openpype.pipeline.editorial import is_overlapping_otio_ranges +from openpype.pipeline.editorial import ( + is_overlapping_otio_ranges, + frames_to_timecode +) from ..otio import davinci_export as otio_export @@ -243,11 +246,13 @@ def get_media_pool_item(fpath, root: object = None) -> object: return None -def create_timeline_item(media_pool_item: object, - timeline: object = None, - source_start: int = None, - source_end: int = None, - timeline_in: int = None) -> object: +def create_timeline_item( + media_pool_item: object, + source_start: int, + source_end: int, + timeline_in: int, + timeline: object = None +) -> object: """ Add media pool item to current or defined timeline. @@ -267,20 +272,24 @@ def create_timeline_item(media_pool_item: object, clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() + # timing variables + fps = project.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data - clip_data = {"mediaPoolItem": media_pool_item} - - # add source time range if input was given - if source_start is not None: - clip_data["startFrame"] = source_start - if source_end is not None: - clip_data["endFrame"] = source_end - - # Create a clipInfo dictionary with the necessary information - clip_data["recordFrame"] = timeline_in + clip_data = { + "mediaPoolItem": media_pool_item, + "startFrame": source_start, + "endFrame": source_end, + "recordFrame": timeline_in, + } + print("clip_data", "_" * 50) + print(media_pool_item.GetName()) print(clip_data) # add to timeline @@ -289,10 +298,15 @@ def create_timeline_item(media_pool_item: object, output_timeline_item = get_timeline_item( media_pool_item, timeline) - assert output_timeline_item, AssertionError( - "Track Item with name `{}` doesn't exist on the timeline: `{}`".format( - clip_name, timeline.GetName() - )) + assert output_timeline_item, AssertionError(( + "Clip name '{}' was't created on the timeline: '{}' \n\n" + "Please check if the clip is in the media pool or if the timeline \n" + "is having activated correct track name, or if it is not already \n" + "having any clip add place on the timeline in: '{}' out: '{}'. \n\n" + "Clip data: {}" + ).format( + clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data + )) return output_timeline_item diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index b1bde212fe..1c817d8e0d 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -355,7 +355,6 @@ class ClipLoader: asset = str(repr_cntx["asset"]) subset = str(repr_cntx["subset"]) representation = str(repr_cntx["representation"]) - self.data["track_name"] = "{}_{}".format(asset, representation) self.data["clip_name"] = "_".join([asset, subset, representation]) self.data["versionData"] = self.context["version"]["data"] # gets file path @@ -387,32 +386,6 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def _set_active_track(self): - """ Set active track to `track` """ - track_type = "video" - track_name = self.data["track_name"] - track_exists = False - - # get total track count - track_count = self.active_timeline.GetTrackCount(track_type) - # loop all tracks by track indexes - for track_index in range(1, int(track_count) + 1): - # get current track name - _track_name = self.active_timeline.GetTrackName( - track_type, track_index) - if track_name != _track_name: - continue - track_exists = True - break - - if not track_exists: - self.active_timeline.AddTrack(track_type) - self.active_timeline.SetTrackName( - track_type, - track_index + 1, - track_name - ) - def load(self): # create project bin for the media to be imported into @@ -420,7 +393,6 @@ class ClipLoader: # create mediaItem in active project bin # create clip media - media_pool_item = lib.create_media_pool_item( self.data["path"], self.active_bin) _clip_property = media_pool_item.GetClipProperty @@ -433,9 +405,6 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) - # handle timeline tracks - self._set_active_track() - # get timeline in timeline_start = self.active_timeline.GetStartFrame() if self.sequential_load: @@ -454,17 +423,17 @@ class ClipLoader: source_out -= handle_end # include handles - if self.with_handles: - source_in -= handle_start - source_out += handle_end + if not self.with_handles: + source_in += handle_start + source_out -= handle_end # make track item from source in bin as item timeline_item = lib.create_timeline_item( media_pool_item, - self.active_timeline, source_in, source_out, - timeline_in + timeline_in, + self.active_timeline, ) print("Loading clips: `{}`".format(self.data["clip_name"])) @@ -504,7 +473,7 @@ class TimelineItemLoader(LoaderPlugin): """ options = [ - qargparse.Toggle( + qargparse.Boolean( "handles", label="Include handles", default=0, From 908b8e3fb69a2347d6b6208f32c546dc55ba061a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Oct 2023 12:22:01 +0200 Subject: [PATCH 010/128] Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator --- openpype/hosts/maya/api/plugin.py | 3 +- .../hosts/maya/plugins/load/load_reference.py | 100 +++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3b54954c8a..07167a9a32 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -771,7 +771,8 @@ class ReferenceLoader(Loader): "ma": "mayaAscii", "mb": "mayaBinary", "abc": "Alembic", - "fbx": "FBX" + "fbx": "FBX", + "usd": "USD Import" }.get(representation["name"]) assert file_type, "Unsupported representation: %s" % representation diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 4b704fa706..0d7f08d3c3 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,7 +1,9 @@ import os import difflib import contextlib + from maya import cmds +import qargparse from openpype.settings import get_project_settings import openpype.hosts.maya.api.plugin @@ -128,6 +130,12 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if not attach_to_root: group_name = namespace + kwargs = {} + if "file_options" in options: + kwargs["options"] = options["file_options"] + if "file_type" in options: + kwargs["type"] = options["file_type"] + path = self.filepath_from_context(context) with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) @@ -139,7 +147,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): reference=True, returnNewNodes=True, groupReference=attach_to_root, - groupName=group_name) + groupName=group_name, + **kwargs) shapes = cmds.ls(nodes, shapes=True, long=True) @@ -251,3 +260,92 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): else: self.log.warning("This version of Maya does not support locking of" " transforms of cameras.") + + +class MayaUSDReferenceLoader(ReferenceLoader): + """Reference USD file to native Maya nodes using MayaUSDImport reference""" + + families = ["usd"] + representations = ["usd"] + extensions = {"usd", "usda", "usdc"} + + options = ReferenceLoader.options + [ + qargparse.Boolean( + "readAnimData", + label="Load anim data", + default=True, + help="Load animation data from USD file" + ), + qargparse.Boolean( + "useAsAnimationCache", + label="Use as animation cache", + default=True, + help=( + "Imports geometry prims with time-sampled point data using a " + "point-based deformer that references the imported " + "USD file.\n" + "This provides better import and playback performance when " + "importing time-sampled geometry from USD, and should " + "reduce the weight of the resulting Maya scene." + ) + ), + qargparse.Boolean( + "importInstances", + label="Import instances", + default=True, + help=( + "Import USD instanced geometries as Maya instanced shapes. " + "Will flatten the scene otherwise." + ) + ), + qargparse.String( + "primPath", + label="Prim Path", + default="/", + help=( + "Name of the USD scope where traversing will begin.\n" + "The prim at the specified primPath (including the prim) will " + "be imported.\n" + "Specifying the pseudo-root (/) means you want " + "to import everything in the file.\n" + "If the passed prim path is empty, it will first try to " + "import the defaultPrim for the rootLayer if it exists.\n" + "Otherwise, it will behave as if the pseudo-root was passed " + "in." + ) + ) + ] + + file_type = "USD Import" + + def process_reference(self, context, name, namespace, options): + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + def bool_option(key, default): + # Shorthand for getting optional boolean file option from options + value = int(bool(options.get(key, default))) + return "{}={}".format(key, value) + + def string_option(key, default): + # Shorthand for getting optional string file option from options + value = str(options.get(key, default)) + return "{}={}".format(key, value) + + options["file_options"] = ";".join([ + string_option("primPath", default="/"), + bool_option("importInstances", default=True), + bool_option("useAsAnimationCache", default=True), + bool_option("readAnimData", default=True), + # TODO: Expose more parameters + # "preferredMaterial=none", + # "importRelativeTextures=Automatic", + # "useCustomFrameRange=0", + # "startTime=0", + # "endTime=0", + # "importUSDZTextures=0" + ]) + options["file_type"] = self.file_type + + return super(MayaUSDReferenceLoader, self).process_reference( + context, name, namespace, options + ) From b1ea0b099dd13e13515d3a4f32b2830702496488 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 15:32:42 +0200 Subject: [PATCH 011/128] merging if conditions for handles exclusion also updating docstring --- openpype/hosts/resolve/api/lib.py | 5 +++-- openpype/hosts/resolve/api/plugin.py | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 22be929412..2d91609679 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -258,9 +258,10 @@ def create_timeline_item( Args: media_pool_item (resolve.MediaPoolItem): resolve's object + source_start (int): media source input frame (sequence frame) + source_end (int): media source output frame (sequence frame) + timeline_in (int): timeline input frame (sequence frame) timeline (resolve.Timeline)[optional]: resolve's object - source_start (int)[optional]: media source input frame (sequence frame) - source_end (int)[optional]: media source output frame (sequence frame) Returns: object: resolve.TimelineItem diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 1c817d8e0d..314d066890 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -418,12 +418,11 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) - if _clip_property("Type") == "Video": - source_in += handle_start - source_out -= handle_end - # include handles - if not self.with_handles: + if ( + not self.with_handles + or _clip_property("Type") == "Video" + ): source_in += handle_start source_out -= handle_end From 9e61d7e371d54a500cc1c67dd3c560d8cfcf98c1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 17:39:10 +0200 Subject: [PATCH 012/128] removing debug printing --- openpype/hosts/resolve/api/lib.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index ca79dd6e87..dfe4608a46 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -292,10 +292,6 @@ def create_timeline_item( "recordFrame": timeline_in, } - print("clip_data", "_" * 50) - print(media_pool_item.GetName()) - print(clip_data) - # add to timeline media_pool.AppendToTimeline([clip_data]) @@ -511,7 +507,7 @@ def imprint(timeline_item, data=None): Arguments: timeline_item (hiero.core.TrackItem): hiero track item object - data (dict): Any data which needst to be imprinted + data (dict): Any data which needs to be imprinted Examples: data = { From 3bbc3d0489db4ea0d785416e6459c79fee34701e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 17 Oct 2023 17:42:46 +0200 Subject: [PATCH 013/128] Update openpype/hosts/resolve/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/resolve/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index dfe4608a46..20636f299b 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -251,10 +251,10 @@ def get_media_pool_item(filepath, root: object = None) -> object: def create_timeline_item( media_pool_item: object, - source_start: int, - source_end: int, - timeline_in: int, - timeline: object = None + timeline: object = None, + timeline_in: int = None, + source_start: int = None, + source_end: int = None, ) -> object: """ Add media pool item to current or defined timeline. From 435408dc142cb934424333257f8092db37974b01 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 17:47:23 +0200 Subject: [PATCH 014/128] create_timeline_item with backward comapatibility --- openpype/hosts/resolve/api/lib.py | 30 ++++++++++++++++++---------- openpype/hosts/resolve/api/plugin.py | 4 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 20636f299b..6f84d921e0 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -261,10 +261,10 @@ def create_timeline_item( Args: media_pool_item (resolve.MediaPoolItem): resolve's object - source_start (int): media source input frame (sequence frame) - source_end (int): media source output frame (sequence frame) - timeline_in (int): timeline input frame (sequence frame) - timeline (resolve.Timeline)[optional]: resolve's object + timeline (Optional[resolve.Timeline]): resolve's object + timeline_in (Optional[int]): timeline input frame (sequence frame) + source_start (Optional[int]): media source input frame (sequence frame) + source_end (Optional[int]): media source output frame (sequence frame) Returns: object: resolve.TimelineItem @@ -277,21 +277,29 @@ def create_timeline_item( timeline = timeline or get_current_timeline() # timing variables - fps = project.GetSetting("timelineFrameRate") - duration = source_end - source_start - timecode_in = frames_to_timecode(timeline_in, fps) - timecode_out = frames_to_timecode(timeline_in + duration, fps) + if all([timeline_in, source_start, source_end]): + fps = project.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + else: + timecode_in = None + timecode_out = None # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data clip_data = { "mediaPoolItem": media_pool_item, - "startFrame": source_start, - "endFrame": source_end, - "recordFrame": timeline_in, } + if source_start: + clip_data["startFrame"] = source_start + if source_end: + clip_data["endFrame"] = source_end + if timecode_in: + clip_data["recordFrame"] = timecode_in + # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 95f2fb2281..c88ed762ab 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -434,10 +434,10 @@ class ClipLoader: # make track item from source in bin as item timeline_item = lib.create_timeline_item( media_pool_item, + self.active_timeline, + timeline_in, source_in, source_out, - timeline_in, - self.active_timeline, ) print("Loading clips: `{}`".format(self.data["clip_name"])) From a810dfe78a97eb1467d3745ee786f2743f6faac2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:30:57 +0200 Subject: [PATCH 015/128] fixing assert message --- openpype/hosts/resolve/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 6f84d921e0..798c40a864 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -308,9 +308,9 @@ def create_timeline_item( assert output_timeline_item, AssertionError(( "Clip name '{}' was't created on the timeline: '{}' \n\n" - "Please check if the clip is in the media pool or if the timeline \n" - "is having activated correct track name, or if it is not already \n" - "having any clip add place on the timeline in: '{}' out: '{}'. \n\n" + "Please check if correct track position is activated, \n" + "or if a clip is not already at the timeline in \n" + "position: '{}' out: '{}'. \n\n" "Clip data: {}" ).format( clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data From a11467883b5d1cec302f43361e7d939ff502ac07 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:34:12 +0200 Subject: [PATCH 016/128] getting fps from timeline instead of project --- openpype/hosts/resolve/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 798c40a864..aef9caca78 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -278,7 +278,7 @@ def create_timeline_item( # timing variables if all([timeline_in, source_start, source_end]): - fps = project.GetSetting("timelineFrameRate") + fps = timeline.GetSetting("timelineFrameRate") duration = source_end - source_start timecode_in = frames_to_timecode(timeline_in, fps) timecode_out = frames_to_timecode(timeline_in + duration, fps) From fbc24f565415cc6cbb88547c9b333bf41a52a3e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:48:50 +0200 Subject: [PATCH 017/128] make sure handles on timeline are included only if available or demanded --- openpype/hosts/resolve/api/plugin.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index c88ed762ab..9a09685bee 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -410,6 +410,18 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) + # check frame duration from versionData or assetData + frame_start = self.data["versionData"].get("frameStart") + if frame_start is None: + frame_start = self.data["assetData"]["frameStart"] + + # check frame duration from versionData or assetData + frame_end = self.data["versionData"].get("frameEnd") + if frame_end is None: + frame_end = self.data["assetData"]["frameEnd"] + + db_frame_duration = int(frame_end) - int(frame_start) + 1 + # get timeline in timeline_start = self.active_timeline.GetStartFrame() if self.sequential_load: @@ -423,10 +435,17 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) - # include handles + # check if source duration is shorter than db frame duration + source_with_handles = True + source_duration = source_out - source_in + 1 + if source_duration < db_frame_duration: + source_with_handles = False + + # only exclude handles if source has no handles or + # if user wants to load without handles if ( not self.with_handles - or _clip_property("Type") == "Video" + or not source_with_handles ): source_in += handle_start source_out -= handle_end From ed2756aa52372b296b5e0307e763339dd5890250 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 13:05:23 +0200 Subject: [PATCH 018/128] improving code readability --- openpype/hosts/resolve/api/plugin.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 9a09685bee..63da14b1c2 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -306,14 +306,18 @@ class ClipLoader: self.active_project = lib.get_current_project() # try to get value from options or evaluate key value for `handles` - self.with_handles = options.get("handles") or bool( - options.get("handles") is True) + self.with_handles = options.get("handles") is True + # try to get value from options or evaluate key value for `load_to` - self.new_timeline = options.get("newTimeline") or bool( - "New timeline" in options.get("load_to", "")) + self.new_timeline = ( + options.get("newTimeline") or + options.get("load_to") == "New timeline" + ) # try to get value from options or evaluate key value for `load_how` - self.sequential_load = options.get("sequentially") or bool( - "Sequentially in order" in options.get("load_how", "")) + self.sequential_load = ( + options.get("sequentially") or + options.get("load_how") == "Sequentially in order" + ) assert self._populate_data(), str( "Cannot Load selected data, look into database " From aa74d48836c990a66413e821bd09628e5480a00f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 14:00:57 +0200 Subject: [PATCH 019/128] clip duration from Frames clip attributes --- openpype/hosts/resolve/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 63da14b1c2..5c4a92df89 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -438,10 +438,10 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) + source_duration = int(_clip_property("Frames")) # check if source duration is shorter than db frame duration source_with_handles = True - source_duration = source_out - source_in + 1 if source_duration < db_frame_duration: source_with_handles = False From 9868b09c9bbd546d98148c7a80c087b87f84a766 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 15:58:34 +0200 Subject: [PATCH 020/128] prepared context dialog using AYON calls --- openpype/tools/context_dialog/_ayon_window.py | 783 ++++++++++++++++++ .../tools/context_dialog/_openpype_window.py | 396 +++++++++ openpype/tools/context_dialog/window.py | 402 +-------- 3 files changed, 1188 insertions(+), 393 deletions(-) create mode 100644 openpype/tools/context_dialog/_ayon_window.py create mode 100644 openpype/tools/context_dialog/_openpype_window.py diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py new file mode 100644 index 0000000000..6514780236 --- /dev/null +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -0,0 +1,783 @@ +import os +import json + +import ayon_api +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.lib.events import QueuedEventSystem +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.utils.lib import ( + center_window, + get_openpype_qt_app, +) + + +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_id = None + self._task_name = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class ContextDialogController: + def __init__(self): + self._event_system = None + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + + self._confirmed = False + self._is_strict = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + def reset(self): + self._emit_event("controller.reset.started") + + self._confirmed = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.reset.finished") + + def refresh(self): + self._emit_event("controller.refresh.started") + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.refresh.finished") + + # Event handling + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._get_event_system().emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._get_event_system().add_callback(topic, callback) + + def set_output_json_path(self, output_path): + self._output_path = output_path + + def is_strict(self): + return self._is_strict + + def set_strict(self, enabled): + if self._is_strict is enabled: + return + self._is_strict = enabled + self._emit_event("strict.changed", {"strict": enabled}) + + # Data model functions + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + # Expected selection helpers + def set_expected_selection(self, project_name, folder_id): + return self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Selection handling + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def is_initial_context_valid(self): + return self._initial_folder_found and self._initial_project_found + + def set_initial_context( + self, project_name=None, asset_name=None, folder_path=None + ): + if project_name is None: + project_found = True + asset_name = None + folder_path = None + + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + + folder_id = None + folder_found = True + folder_label = None + if folder_path: + folder_label = folder_path + folder = ayon_api.get_folder_by_path(project_name, folder_path) + if folder: + folder_id = folder["id"] + else: + folder_found = False + elif asset_name: + folder_label = asset_name + for folder in ayon_api.get_folders( + project_name, folder_names=[asset_name] + ): + folder_id = folder["id"] + break + if not folder_id: + folder_found = False + + tasks_found = True + if folder_found and (folder_path or asset_name): + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + if not tasks: + tasks_found = False + + self._initial_project_name = project_name + self._initial_folder_id = folder_id + self._initial_folder_label = folder_label + self._initial_folder_found = project_found + self._initial_folder_found = folder_found + self._initial_tasks_found = tasks_found + self._emit_event( + "initial.context.changed", + self.get_initial_context() + ) + + def get_initial_context(self): + return { + "project_name": self._initial_project_name, + "folder_id": self._initial_folder_id, + "folder_label": self._initial_folder_label, + "project_found": self._initial_project_found, + "folder_found": self._initial_folder_found, + "tasks_found": self._initial_tasks_found, + "valid": ( + self._initial_project_found + and self._initial_folder_found + and self._initial_tasks_found + ) + } + + # Result of this tool + def get_selected_context(self): + return { + "project": None, + "project_name": None, + "asset": None, + "folder_id": None, + "folder_path": None, + "task": None, + "task_id": None, + "task_name": None, + } + + def window_closed(self): + if not self._confirmed and not self._is_strict: + return + + self._store_output() + + def confirm_selection(self): + self._confirmed = True + self._emit_event( + "selection.confirmed", + {"confirmed": True} + ) + + def _store_output(self): + if not self._output_path: + return + + dirpath = os.path.dirname(self._output_path) + os.makedirs(dirpath, exist_ok=True) + with open(self._output_path, "w") as stream: + json.dump(self.get_selected_context(), stream) + + def _get_event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + +class InvalidContextOverlay(QtWidgets.QFrame): + confirmed = QtCore.Signal() + + def __init__(self, parent): + super(InvalidContextOverlay, self).__init__(parent) + self.setObjectName("OverlayFrame") + + mid_widget = QtWidgets.QWidget(self) + label_widget = QtWidgets.QLabel( + "Requested context was not found...", + mid_widget + ) + + confirm_btn = QtWidgets.QPushButton("Close", mid_widget) + + mid_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + mid_layout = QtWidgets.QVBoxLayout(mid_widget) + mid_layout.setContentsMargins(0, 0, 0, 0) + mid_layout.addWidget(label_widget, 0) + mid_layout.addSpacing(30) + mid_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(mid_widget, 1, 1) + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 0) + main_layout.setRowStretch(2, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 0) + main_layout.setColumnStretch(2, 1) + + confirm_btn.clicked.connect(self.confirmed) + + self._label_widget = label_widget + self._confirm_btn = confirm_btn + + def set_context( + self, + project_name, + folder_label, + project_found, + folder_found, + tasks_found, + ): + lines = [] + if not project_found: + lines.extend([ + "Requested project {} was not found...".format(project_name), + ]) + + elif not folder_found: + lines.extend([ + "Requested folder was not found...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + elif not tasks_found: + lines.extend([ + "Requested folder does not have any tasks...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + else: + lines.append("Requested context was not found...") + self._label_widget.setText("
".join(lines)) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, controller=None, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + if controller is None: + controller = ContextDialogController() + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = ProjectsCombobox( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + # Assets widget + folders_widget = FoldersWidget( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox, 0) + left_side_layout.addWidget(folders_widget, 1) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(controller, parent=main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + overlay_widget = InvalidContextOverlay(self) + overlay_widget.setVisible(False) + + ok_btn.clicked.connect(self._on_ok_click) + project_combobox.refreshed.connect(self._on_projects_refresh) + overlay_widget.confirmed.connect(self._on_overlay_confirm) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_selection_change + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_selection_change + ) + controller.register_event_callback( + "initial.context.changed", + self._on_init_context_change + ) + controller.register_event_callback( + "strict.changed", + self._on_strict_changed + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + + # Set stylehseet and resize window on first show + self._first_show = True + self._visible = False + + self._controller = controller + + self._project_combobox = project_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._overlay_widget = overlay_widget + + self._apply_strict_changes(self.is_strict()) + + def is_strict(self): + return self._controller.is_strict() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + self._visible = True + + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + self._controller.refresh() + + initial_context = self._controller.get_initial_context() + self._set_init_context(initial_context) + self._overlay_widget.resize(self.size()) + + def resizeEvent(self, event): + super(ContextDialog, self).resizeEvent(event) + self._overlay_widget.resize(self.size()) + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self.is_strict() and not self._ok_btn.isEnabled(): + # Allow to close window when initial context is not valid + if self._controller.is_initial_context_valid(): + event.ignore() + return + + if self.is_strict(): + self._controller.confirm_selection() + self._visible = False + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, enabled): + """Change strictness of dialog.""" + + self._controller.set_strict(enabled) + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + + self._controller.reset() + + def get_context(self): + """Result of dialog.""" + return self._controller.get_selected_context() + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + + self._controller.set_initial_context(project_name, asset_name) + + def _on_projects_refresh(self): + initial_context = self._controller.get_initial_context() + self._controller.set_expected_selection( + initial_context["project_name"], + initial_context["folder_id"] + ) + + def _on_overlay_confirm(self): + self.close() + + def _on_ok_click(self): + # Store values to output + self._controller.confirm_selection() + # Close dialog + self.accept() + + def _on_project_selection_change(self, event): + self._on_selection_change( + event["project_name"], + ) + + def _on_folder_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + ) + + def _on_task_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + event["task_name"], + ) + + def _on_selection_change( + self, project_name, folder_id=None, task_name=None + ): + self._validate_strict(project_name, folder_id, task_name) + + def _on_init_context_change(self, event): + self._set_init_context(event.data) + if self._visible: + self._controller.set_expected_selection( + event["project_name"], event["folder_id"] + ) + + def _set_init_context(self, init_context): + project_name = init_context["project_name"] + if not init_context["valid"]: + self._overlay_widget.setVisible(True) + self._overlay_widget.set_context( + project_name, + init_context["folder_label"], + init_context["project_found"], + init_context["folder_found"], + init_context["tasks_found"] + ) + return + + self._overlay_widget.setVisible(False) + if project_name: + self._project_combobox.setEnabled(False) + if init_context["folder_id"]: + self._folders_widget.setEnabled(False) + else: + self._project_combobox.setEnabled(True) + self._folders_widget.setEnabled(True) + + def _on_strict_changed(self, event): + self._apply_strict_changes(event["strict"]) + + def _on_controller_reset(self): + self._apply_strict_changes(self.is_strict()) + self._project_combobox.refresh() + + def _on_controller_refresh(self): + self._project_combobox.refresh() + + def _apply_strict_changes(self, is_strict): + if not is_strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + context = self._controller.get_selected_context() + self._validate_strict( + context["project_name"], + context["folder_id"], + context["task_name"] + ) + + def _validate_strict(self, project_name, folder_id, task_name): + if not self.is_strict(): + return + + enabled = True + if not project_name or not folder_id or not task_name: + enabled = False + self._ok_btn.setEnabled(enabled) + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + controller = ContextDialogController() + controller.set_strict(strict) + controller.set_initial_context(project_name, asset_name) + controller.set_output_json_path(path_to_store) + window = ContextDialog(controller=controller) + window.show() + app.exec_() + + # Get result from window + data = window.get_context() + + # Make sure json filepath directory exists + file_dir = os.path.dirname(path_to_store) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # Store result into json file + with open(path_to_store, "w") as stream: + json.dump(data, stream) diff --git a/openpype/tools/context_dialog/_openpype_window.py b/openpype/tools/context_dialog/_openpype_window.py new file mode 100644 index 0000000000..d370772a7f --- /dev/null +++ b/openpype/tools/context_dialog/_openpype_window.py @@ -0,0 +1,396 @@ +import os +import json + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.pipeline import AvalonMongoDB +from openpype.tools.utils.lib import center_window, get_openpype_qt_app +from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget +from openpype.tools.utils.constants import ( + PROJECT_NAME_ROLE +) +from openpype.tools.utils.tasks_widget import TasksWidget +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + dbcon = AvalonMongoDB() + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(left_side_widget) + # Styled delegate to propagate stylessheet + project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) + project_combobox.setItemDelegate(project_delegate) + # Project model with only active projects without default item + project_model = ProjectModel( + dbcon, + only_active=True, + add_default_project=False + ) + # Sorting proxy model + project_proxy = ProjectSortFilterProxy() + project_proxy.setSourceModel(project_model) + project_combobox.setModel(project_proxy) + + # Assets widget + assets_widget = SingleSelectAssetsWidget( + dbcon, parent=left_side_widget + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox) + left_side_layout.addWidget(assets_widget) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(dbcon, main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + # Timer which will trigger asset refresh + # - this is needed because asset widget triggers + # finished refresh before hides spin box so we need to trigger + # refreshing in small offset if we want re-refresh asset widget + assets_timer = QtCore.QTimer() + assets_timer.setInterval(50) + assets_timer.setSingleShot(True) + + assets_timer.timeout.connect(self._on_asset_refresh_timer) + + project_combobox.currentIndexChanged.connect( + self._on_project_combo_change + ) + assets_widget.selection_changed.connect(self._on_asset_change) + assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) + assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) + tasks_widget.task_changed.connect(self._on_task_change) + ok_btn.clicked.connect(self._on_ok_click) + + self._dbcon = dbcon + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._assets_widget = assets_widget + + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._strict = False + + # Values set by `set_context` method + self._set_context_project = None + self._set_context_asset = None + + # Requirements for asset widget refresh + self._assets_timer = assets_timer + self._rerefresh_assets = True + self._assets_refreshing = False + + # Set stylehseet and resize window on first show + self._first_show = True + + # Helper attributes for handling of refresh + self._ignore_value_changes = False + self._refresh_on_next_show = True + + # Output of dialog + self._context_to_store = { + "project": None, + "asset": None, + "task": None + } + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self._strict and not self._ok_btn.isEnabled(): + event.ignore() + return + + if self._strict: + self._confirm_values() + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, strict): + """Change strictness of dialog.""" + self._strict = strict + self._validate_strict() + + def _set_refresh_on_next_show(self): + """Refresh will be called on next showEvent. + + If window is already visible then just execute refresh. + """ + self._refresh_on_next_show = True + if self.isVisible(): + self.refresh() + + def _refresh_assets(self): + """Trigger refreshing of asset widget. + + This will set mart to rerefresh asset when current refreshing is done + or do it immidietely if asset widget is not refreshing at the time. + """ + if self._assets_refreshing: + self._rerefresh_assets = True + else: + self._on_asset_refresh_timer() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + + if self._refresh_on_next_show: + self.refresh() + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + # Change state of refreshing (no matter how refresh was called) + self._refresh_on_next_show = False + + # Ignore changes of combobox and asset widget + self._ignore_value_changes = True + + # Get current project name to be able set it afterwards + select_project_name = self._dbcon.Session.get("AVALON_PROJECT") + # Trigger project refresh + self._project_model.refresh() + # Sort projects + self._project_proxy.sort(0) + + # Disable combobox if project was passed to `set_context` + if self._set_context_project: + select_project_name = self._set_context_project + self._project_combobox.setEnabled(False) + else: + # Find new project to select + self._project_combobox.setEnabled(True) + if ( + select_project_name is None + and self._project_proxy.rowCount() > 0 + ): + index = self._project_proxy.index(0, 0) + select_project_name = index.data(PROJECT_NAME_ROLE) + + self._ignore_value_changes = False + + idx = self._project_combobox.findText(select_project_name) + if idx >= 0: + self._project_combobox.setCurrentIndex(idx) + self._dbcon.Session["AVALON_PROJECT"] = ( + self._project_combobox.currentText() + ) + + # Trigger asset refresh + self._refresh_assets() + + def _on_asset_refresh_timer(self): + """This is only way how to trigger refresh asset widget. + + Use `_refresh_assets` method to refresh asset widget. + """ + self._assets_widget.refresh() + + def _on_asset_widget_refresh_finished(self): + """Catch when asset widget finished refreshing.""" + # If should refresh again then skip all other callbacks and trigger + # assets timer directly. + self._assets_refreshing = False + if self._rerefresh_assets: + self._rerefresh_assets = False + self._assets_timer.start() + return + + self._ignore_value_changes = True + if self._set_context_asset: + self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset + self._assets_widget.setEnabled(False) + self._assets_widget.select_asset_by_name(self._set_context_asset) + self._set_asset_to_tasks_widget() + else: + self._assets_widget.setEnabled(True) + self._assets_widget.set_current_asset_btn_visibility(False) + + # Refresh tasks + self._tasks_widget.refresh() + + self._ignore_value_changes = False + + self._validate_strict() + + def _on_project_combo_change(self): + if self._ignore_value_changes: + return + project_name = self._project_combobox.currentText() + + if self._dbcon.Session.get("AVALON_PROJECT") == project_name: + return + + self._dbcon.Session["AVALON_PROJECT"] = project_name + + self._refresh_assets() + self._validate_strict() + + def _on_asset_refresh_trigger(self): + self._assets_refreshing = True + self._on_asset_change() + + def _on_asset_change(self): + """Selected assets have changed""" + if self._ignore_value_changes: + return + self._set_asset_to_tasks_widget() + + def _on_task_change(self): + self._validate_strict() + + def _set_asset_to_tasks_widget(self): + asset_id = self._assets_widget.get_selected_asset_id() + + self._tasks_widget.set_asset_id(asset_id) + + def _confirm_values(self): + """Store values to output.""" + self._context_to_store["project"] = self.get_selected_project() + self._context_to_store["asset"] = self.get_selected_asset() + self._context_to_store["task"] = self.get_selected_task() + + def _on_ok_click(self): + # Store values to output + self._confirm_values() + # Close dialog + self.accept() + + def get_selected_project(self): + """Get selected project.""" + return self._project_combobox.currentText() + + def get_selected_asset(self): + """Currently selected asset in asset widget.""" + return self._assets_widget.get_selected_asset_name() + + def get_selected_task(self): + """Currently selected task.""" + return self._tasks_widget.get_selected_task_name() + + def _validate_strict(self): + if not self._strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + + enabled = True + if not self._set_context_project and not self.get_selected_project(): + enabled = False + elif not self._set_context_asset and not self.get_selected_asset(): + enabled = False + elif not self.get_selected_task(): + enabled = False + self._ok_btn.setEnabled(enabled) + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + if project_name is None: + asset_name = None + + self._set_context_project = project_name + self._set_context_asset = asset_name + + self._context_to_store["project"] = project_name + self._context_to_store["asset"] = asset_name + + self._set_refresh_on_next_show() + + def get_context(self): + """Result of dialog.""" + return self._context_to_store + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + window = ContextDialog() + window.set_strict(strict) + window.set_context(project_name, asset_name) + window.show() + app.exec_() + + # Get result from window + data = window.get_context() + + # Make sure json filepath directory exists + file_dir = os.path.dirname(path_to_store) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # Store result into json file + with open(path_to_store, "w") as stream: + json.dump(data, stream) diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py index 4fe41c9949..15b90463da 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/window.py @@ -1,396 +1,12 @@ -import os -import json +from openpype import AYON_SERVER_ENABLED -from qtpy import QtWidgets, QtCore, QtGui +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main -from openpype import style -from openpype.pipeline import AvalonMongoDB -from openpype.tools.utils.lib import center_window, get_openpype_qt_app -from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget -from openpype.tools.utils.constants import ( - PROJECT_NAME_ROLE + +__all__ = ( + "ContextDialog", + "main", ) -from openpype.tools.utils.tasks_widget import TasksWidget -from openpype.tools.utils.models import ( - ProjectModel, - ProjectSortFilterProxy -) - - -class ContextDialog(QtWidgets.QDialog): - """Dialog to select a context. - - Context has 3 parts: - - Project - - Aseet - - Task - - It is possible to predefine project and asset. In that case their widgets - will have passed preselected values and will be disabled. - """ - def __init__(self, parent=None): - super(ContextDialog, self).__init__(parent) - - self.setWindowTitle("Select Context") - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) - - # Enable minimize and maximize for app - window_flags = QtCore.Qt.Window - if not parent: - window_flags |= QtCore.Qt.WindowStaysOnTopHint - self.setWindowFlags(window_flags) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - dbcon = AvalonMongoDB() - - # UI initialization - main_splitter = QtWidgets.QSplitter(self) - - # Left side widget contains project combobox and asset widget - left_side_widget = QtWidgets.QWidget(main_splitter) - - project_combobox = QtWidgets.QComboBox(left_side_widget) - # Styled delegate to propagate stylessheet - project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) - project_combobox.setItemDelegate(project_delegate) - # Project model with only active projects without default item - project_model = ProjectModel( - dbcon, - only_active=True, - add_default_project=False - ) - # Sorting proxy model - project_proxy = ProjectSortFilterProxy() - project_proxy.setSourceModel(project_model) - project_combobox.setModel(project_proxy) - - # Assets widget - assets_widget = SingleSelectAssetsWidget( - dbcon, parent=left_side_widget - ) - - left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) - left_side_layout.setContentsMargins(0, 0, 0, 0) - left_side_layout.addWidget(project_combobox) - left_side_layout.addWidget(assets_widget) - - # Right side of window contains only tasks - tasks_widget = TasksWidget(dbcon, main_splitter) - - # Add widgets to main splitter - main_splitter.addWidget(left_side_widget) - main_splitter.addWidget(tasks_widget) - - # Set stretch of both sides - main_splitter.setStretchFactor(0, 7) - main_splitter.setStretchFactor(1, 3) - - # Add confimation button to bottom right - ok_btn = QtWidgets.QPushButton("OK", self) - - buttons_layout = QtWidgets.QHBoxLayout() - buttons_layout.setContentsMargins(0, 0, 0, 0) - buttons_layout.addStretch(1) - buttons_layout.addWidget(ok_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(main_splitter, 1) - main_layout.addLayout(buttons_layout, 0) - - # Timer which will trigger asset refresh - # - this is needed because asset widget triggers - # finished refresh before hides spin box so we need to trigger - # refreshing in small offset if we want re-refresh asset widget - assets_timer = QtCore.QTimer() - assets_timer.setInterval(50) - assets_timer.setSingleShot(True) - - assets_timer.timeout.connect(self._on_asset_refresh_timer) - - project_combobox.currentIndexChanged.connect( - self._on_project_combo_change - ) - assets_widget.selection_changed.connect(self._on_asset_change) - assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) - assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) - tasks_widget.task_changed.connect(self._on_task_change) - ok_btn.clicked.connect(self._on_ok_click) - - self._dbcon = dbcon - - self._project_combobox = project_combobox - self._project_model = project_model - self._project_proxy = project_proxy - self._project_delegate = project_delegate - - self._assets_widget = assets_widget - - self._tasks_widget = tasks_widget - - self._ok_btn = ok_btn - - self._strict = False - - # Values set by `set_context` method - self._set_context_project = None - self._set_context_asset = None - - # Requirements for asset widget refresh - self._assets_timer = assets_timer - self._rerefresh_assets = True - self._assets_refreshing = False - - # Set stylehseet and resize window on first show - self._first_show = True - - # Helper attributes for handling of refresh - self._ignore_value_changes = False - self._refresh_on_next_show = True - - # Output of dialog - self._context_to_store = { - "project": None, - "asset": None, - "task": None - } - - def closeEvent(self, event): - """Ignore close event if is in strict state and context is not done.""" - if self._strict and not self._ok_btn.isEnabled(): - event.ignore() - return - - if self._strict: - self._confirm_values() - super(ContextDialog, self).closeEvent(event) - - def set_strict(self, strict): - """Change strictness of dialog.""" - self._strict = strict - self._validate_strict() - - def _set_refresh_on_next_show(self): - """Refresh will be called on next showEvent. - - If window is already visible then just execute refresh. - """ - self._refresh_on_next_show = True - if self.isVisible(): - self.refresh() - - def _refresh_assets(self): - """Trigger refreshing of asset widget. - - This will set mart to rerefresh asset when current refreshing is done - or do it immidietely if asset widget is not refreshing at the time. - """ - if self._assets_refreshing: - self._rerefresh_assets = True - else: - self._on_asset_refresh_timer() - - def showEvent(self, event): - """Override show event to do some callbacks.""" - super(ContextDialog, self).showEvent(event) - if self._first_show: - self._first_show = False - # Set stylesheet and resize - self.setStyleSheet(style.load_stylesheet()) - self.resize(600, 700) - center_window(self) - - if self._refresh_on_next_show: - self.refresh() - - def refresh(self): - """Refresh all widget one by one. - - When asset refresh is triggered we have to wait when is done so - this method continues with `_on_asset_widget_refresh_finished`. - """ - # Change state of refreshing (no matter how refresh was called) - self._refresh_on_next_show = False - - # Ignore changes of combobox and asset widget - self._ignore_value_changes = True - - # Get current project name to be able set it afterwards - select_project_name = self._dbcon.Session.get("AVALON_PROJECT") - # Trigger project refresh - self._project_model.refresh() - # Sort projects - self._project_proxy.sort(0) - - # Disable combobox if project was passed to `set_context` - if self._set_context_project: - select_project_name = self._set_context_project - self._project_combobox.setEnabled(False) - else: - # Find new project to select - self._project_combobox.setEnabled(True) - if ( - select_project_name is None - and self._project_proxy.rowCount() > 0 - ): - index = self._project_proxy.index(0, 0) - select_project_name = index.data(PROJECT_NAME_ROLE) - - self._ignore_value_changes = False - - idx = self._project_combobox.findText(select_project_name) - if idx >= 0: - self._project_combobox.setCurrentIndex(idx) - self._dbcon.Session["AVALON_PROJECT"] = ( - self._project_combobox.currentText() - ) - - # Trigger asset refresh - self._refresh_assets() - - def _on_asset_refresh_timer(self): - """This is only way how to trigger refresh asset widget. - - Use `_refresh_assets` method to refresh asset widget. - """ - self._assets_widget.refresh() - - def _on_asset_widget_refresh_finished(self): - """Catch when asset widget finished refreshing.""" - # If should refresh again then skip all other callbacks and trigger - # assets timer directly. - self._assets_refreshing = False - if self._rerefresh_assets: - self._rerefresh_assets = False - self._assets_timer.start() - return - - self._ignore_value_changes = True - if self._set_context_asset: - self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset - self._assets_widget.setEnabled(False) - self._assets_widget.select_assets(self._set_context_asset) - self._set_asset_to_tasks_widget() - else: - self._assets_widget.setEnabled(True) - self._assets_widget.set_current_asset_btn_visibility(False) - - # Refresh tasks - self._tasks_widget.refresh() - - self._ignore_value_changes = False - - self._validate_strict() - - def _on_project_combo_change(self): - if self._ignore_value_changes: - return - project_name = self._project_combobox.currentText() - - if self._dbcon.Session.get("AVALON_PROJECT") == project_name: - return - - self._dbcon.Session["AVALON_PROJECT"] = project_name - - self._refresh_assets() - self._validate_strict() - - def _on_asset_refresh_trigger(self): - self._assets_refreshing = True - self._on_asset_change() - - def _on_asset_change(self): - """Selected assets have changed""" - if self._ignore_value_changes: - return - self._set_asset_to_tasks_widget() - - def _on_task_change(self): - self._validate_strict() - - def _set_asset_to_tasks_widget(self): - asset_id = self._assets_widget.get_selected_asset_id() - - self._tasks_widget.set_asset_id(asset_id) - - def _confirm_values(self): - """Store values to output.""" - self._context_to_store["project"] = self.get_selected_project() - self._context_to_store["asset"] = self.get_selected_asset() - self._context_to_store["task"] = self.get_selected_task() - - def _on_ok_click(self): - # Store values to output - self._confirm_values() - # Close dialog - self.accept() - - def get_selected_project(self): - """Get selected project.""" - return self._project_combobox.currentText() - - def get_selected_asset(self): - """Currently selected asset in asset widget.""" - return self._assets_widget.get_selected_asset_name() - - def get_selected_task(self): - """Currently selected task.""" - return self._tasks_widget.get_selected_task_name() - - def _validate_strict(self): - if not self._strict: - if not self._ok_btn.isEnabled(): - self._ok_btn.setEnabled(True) - return - - enabled = True - if not self._set_context_project and not self.get_selected_project(): - enabled = False - elif not self._set_context_asset and not self.get_selected_asset(): - enabled = False - elif not self.get_selected_task(): - enabled = False - self._ok_btn.setEnabled(enabled) - - def set_context(self, project_name=None, asset_name=None): - """Set context which will be used and locked in dialog.""" - if project_name is None: - asset_name = None - - self._set_context_project = project_name - self._set_context_asset = asset_name - - self._context_to_store["project"] = project_name - self._context_to_store["asset"] = asset_name - - self._set_refresh_on_next_show() - - def get_context(self): - """Result of dialog.""" - return self._context_to_store - - -def main( - path_to_store, - project_name=None, - asset_name=None, - strict=True -): - # Run Qt application - app = get_openpype_qt_app() - window = ContextDialog() - window.set_strict(strict) - window.set_context(project_name, asset_name) - window.show() - app.exec_() - - # Get result from window - data = window.get_context() - - # Make sure json filepath directory exists - file_dir = os.path.dirname(path_to_store) - if not os.path.exists(file_dir): - os.makedirs(file_dir) - - # Store result into json file - with open(path_to_store, "w") as stream: - json.dump(data, stream) From b374bf7eaebcb151a38447593ab54864e4cc65ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:47:21 +0200 Subject: [PATCH 021/128] fix storing of data --- openpype/tools/context_dialog/_ayon_window.py | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 6514780236..04fd3495e1 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -356,27 +356,17 @@ class ContextDialogController: "task_name": None, } - def window_closed(self): - if not self._confirmed and not self._is_strict: - return - - self._store_output() - def confirm_selection(self): self._confirmed = True - self._emit_event( - "selection.confirmed", - {"confirmed": True} - ) - def _store_output(self): + def store_output(self): if not self._output_path: return dirpath = os.path.dirname(self._output_path) os.makedirs(dirpath, exist_ok=True) with open(self._output_path, "w") as stream: - json.dump(self.get_selected_context(), stream) + json.dump(self.get_selected_context(), stream, indent=4) def _get_event_system(self): """Inner event system for workfiles tool controller. @@ -627,7 +617,7 @@ class ContextDialog(QtWidgets.QDialog): return if self.is_strict(): - self._controller.confirm_selection() + self._confirm_selection() self._visible = False super(ContextDialog, self).closeEvent(event) @@ -666,10 +656,13 @@ class ContextDialog(QtWidgets.QDialog): def _on_ok_click(self): # Store values to output - self._controller.confirm_selection() + self._confirm_selection() # Close dialog self.accept() + def _confirm_selection(self): + self._controller.confirm_selection() + def _on_project_selection_change(self, event): self._on_selection_change( event["project_name"], @@ -769,15 +762,4 @@ def main( window = ContextDialog(controller=controller) window.show() app.exec_() - - # Get result from window - data = window.get_context() - - # Make sure json filepath directory exists - file_dir = os.path.dirname(path_to_store) - if not os.path.exists(file_dir): - os.makedirs(file_dir) - - # Store result into json file - with open(path_to_store, "w") as stream: - json.dump(data, stream) + controller.store_output() From f86874df3de22841587697cc8246eda14ddb4978 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:47:46 +0200 Subject: [PATCH 022/128] add select item to projects combobox --- openpype/tools/context_dialog/_ayon_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 04fd3495e1..07495b7674 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -496,6 +496,7 @@ class ContextDialog(QtWidgets.QDialog): parent=left_side_widget, handle_expected_selection=True ) + project_combobox.set_select_item_visible(True) # Assets widget folders_widget = FoldersWidget( From 28bcbc8053133a1a257078052e9c2115167fea0d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:48:07 +0200 Subject: [PATCH 023/128] implemented helper function to prepare initial context data --- openpype/tools/context_dialog/_ayon_window.py | 136 +++++++++++------- 1 file changed, 84 insertions(+), 52 deletions(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 07495b7674..73f9ed139c 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -277,52 +277,15 @@ class ContextDialogController: def is_initial_context_valid(self): return self._initial_folder_found and self._initial_project_found - def set_initial_context( - self, project_name=None, asset_name=None, folder_path=None - ): - if project_name is None: - project_found = True - asset_name = None - folder_path = None - - else: - project = ayon_api.get_project(project_name) - project_found = project is not None - - folder_id = None - folder_found = True - folder_label = None - if folder_path: - folder_label = folder_path - folder = ayon_api.get_folder_by_path(project_name, folder_path) - if folder: - folder_id = folder["id"] - else: - folder_found = False - elif asset_name: - folder_label = asset_name - for folder in ayon_api.get_folders( - project_name, folder_names=[asset_name] - ): - folder_id = folder["id"] - break - if not folder_id: - folder_found = False - - tasks_found = True - if folder_found and (folder_path or asset_name): - tasks = list(ayon_api.get_tasks( - project_name, folder_ids=[folder_id], fields=["id"] - )) - if not tasks: - tasks_found = False + def set_initial_context(self, project_name=None, asset_name=None): + result = self._prepare_initial_context(project_name, asset_name) self._initial_project_name = project_name - self._initial_folder_id = folder_id - self._initial_folder_label = folder_label - self._initial_folder_found = project_found - self._initial_folder_found = folder_found - self._initial_tasks_found = tasks_found + self._initial_folder_id = result["folder_id"] + self._initial_folder_label = result["folder_label"] + self._initial_project_found = result["project_found"] + self._initial_folder_found = result["folder_found"] + self._initial_tasks_found = result["tasks_found"] self._emit_event( "initial.context.changed", self.get_initial_context() @@ -345,15 +308,36 @@ class ContextDialogController: # Result of this tool def get_selected_context(self): + project_name = None + folder_id = None + task_id = None + task_name = None + folder_path = None + folder_name = None + if self._confirmed: + project_name = self.get_selected_project_name() + folder_id = self.get_selected_folder_id() + task_id = self.get_selected_task_id() + task_name = self.get_selected_task_name() + + folder_item = None + if folder_id: + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + + if folder_item: + folder_path = folder_item.path + folder_name = folder_item.name return { - "project": None, - "project_name": None, - "asset": None, - "folder_id": None, - "folder_path": None, - "task": None, - "task_id": None, - "task_name": None, + "project": project_name, + "project_name": project_name, + "asset": folder_name, + "folder_id": folder_id, + "folder_path": folder_path, + "task": task_name, + "task_name": task_name, + "task_id": task_id, + "initial_context_valid": self.is_initial_context_valid(), } def confirm_selection(self): @@ -368,6 +352,54 @@ class ContextDialogController: with open(self._output_path, "w") as stream: json.dump(self.get_selected_context(), stream, indent=4) + def _prepare_initial_context(self, project_name, asset_name): + project_found = True + output = { + "project_found": project_found, + "folder_id": None, + "folder_label": None, + "folder_found": True, + "tasks_found": True, + } + if project_name is None: + asset_name = None + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + output["project_found"] = project_found + if not project_found or not asset_name: + return output + + output["folder_label"] = asset_name + + folder_id = None + folder_found = False + # First try to find by path + folder = ayon_api.get_folder_by_path(project_name, asset_name) + # Try to find by name if folder was not found by path + # - prevent to query by name if 'asset_name' contains '/' + if not folder and "/" not in asset_name: + folder = next( + ayon_api.get_folders( + project_name, folder_names=[asset_name], fields=["id"]), + None + ) + + if folder: + folder_id = folder["id"] + folder_found = True + + output["folder_id"] = folder_id + output["folder_found"] = folder_found + if not folder_found: + return output + + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + output["tasks_found"] = bool(tasks) + return output + def _get_event_system(self): """Inner event system for workfiles tool controller. From a62718dc72b62a1823513795695a55b0601bb5a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:48:24 +0200 Subject: [PATCH 024/128] project name is in quotes --- openpype/tools/context_dialog/_ayon_window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 73f9ed139c..f347978392 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -468,7 +468,8 @@ class InvalidContextOverlay(QtWidgets.QFrame): lines = [] if not project_found: lines.extend([ - "Requested project {} was not found...".format(project_name), + "Requested project '{}' was not found...".format( + project_name), ]) elif not folder_found: From 2f15dca3f50bbf687efd86bfb8339c284fd6aa7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:51:59 +0200 Subject: [PATCH 025/128] removed 'window.py' --- openpype/tools/context_dialog/__init__.py | 12 +++++++----- openpype/tools/context_dialog/window.py | 12 ------------ 2 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 openpype/tools/context_dialog/window.py diff --git a/openpype/tools/context_dialog/__init__.py b/openpype/tools/context_dialog/__init__.py index 9b10baf903..15b90463da 100644 --- a/openpype/tools/context_dialog/__init__.py +++ b/openpype/tools/context_dialog/__init__.py @@ -1,10 +1,12 @@ -from .window import ( - ContextDialog, - main -) +from openpype import AYON_SERVER_ENABLED + +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main __all__ = ( "ContextDialog", - "main" + "main", ) diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py deleted file mode 100644 index 15b90463da..0000000000 --- a/openpype/tools/context_dialog/window.py +++ /dev/null @@ -1,12 +0,0 @@ -from openpype import AYON_SERVER_ENABLED - -if AYON_SERVER_ENABLED: - from ._ayon_window import ContextDialog, main -else: - from ._openpype_window import ContextDialog, main - - -__all__ = ( - "ContextDialog", - "main", -) From 5b18acadc0c76e7bd51f5fe8a6b1b93486a0e79e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 21:33:31 +0800 Subject: [PATCH 026/128] Implement ValidateAttributes in 3dsMax --- .../plugins/publish/validate_attributes.py | 62 +++++++++++++++++++ openpype/settings/ayon_settings.py | 9 +++ .../defaults/project_settings/max.json | 4 ++ .../schemas/schema_max_publish.json | 19 ++++++ .../max/server/settings/publishers.py | 36 ++++++++++- 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_attributes.py diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py new file mode 100644 index 0000000000..e98e73de06 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Validator for Attributes.""" +from pyblish.api import ContextPlugin, ValidatorOrder +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError, + RepairContextAction +) + + +class ValidateAttributes(OptionalPyblishPluginMixin, + ContextPlugin): + """Validates attributes are consistent in 3ds max.""" + + order = ValidatorOrder + hosts = ["max"] + label = "Attributes" + actions = [RepairContextAction] + optional = True + + @classmethod + def get_invalid(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + if not attributes: + return + + invalid_attributes = [key for key, value in attributes.items() + if rt.Execute(attributes[key]) != value] + + return invalid_attributes + + def process(self, context): + if not self.is_active(context.data): + self.log.debug("Skipping Validate Attributes...") + return + invalid_attributes = self.get_invalid(context) + if invalid_attributes: + bullet_point_invalid_statement = "\n".join( + "- {}".format(invalid) for invalid in invalid_attributes + ) + report = ( + "Required Attribute(s) have invalid value(s).\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to fix it." + ) + raise PublishValidationError( + report, title="Invalid Value(s) for Required Attribute(s)") + + @classmethod + def repair(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + invalid_attribute_keys = cls.get_invalid(context) + for key in invalid_attribute_keys: + attributes[key] = rt.Execute(attributes[key]) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..a31c8a04e0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -639,6 +639,15 @@ def _convert_3dsmax_project_settings(ayon_settings, output): for item in point_cloud_attribute } ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + # --- Publish (START) --- + ayon_publish = ayon_max["publish"] + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes output["max"] = ayon_max diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..24a87020bb 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -36,6 +36,10 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateAttributes": { + "enabled": false, + "attributes": {} } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..c3b56bae5e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,25 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateAttributes", + "label": "ValidateAttributes", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "raw-json", + "key": "attributes", + "label": "Attributes" + } + ] } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a695b85e89..df8412391a 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,6 +1,30 @@ -from pydantic import Field +import json +from pydantic import Field, validator from ayon_server.settings import BaseSettingsModel +from ayon_server.exceptions import BadRequestException + + +class ValidateAttributesModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateAttributes") + attributes: str = Field( + "{}", title="Attributes", widget="textarea") + + @validator("attributes") + def validate_json(cls, value): + if not value.strip(): + return "{}" + try: + converted_value = json.loads(value) + success = isinstance(converted_value, dict) + except json.JSONDecodeError: + success = False + + if not success: + raise BadRequestException( + "The attibutes can't be parsed as json object" + ) + return value class BasicValidateModel(BaseSettingsModel): @@ -15,6 +39,10 @@ class PublishersModel(BaseSettingsModel): title="Validate Frame Range", section="Validators" ) + ValidateAttributes: ValidateAttributesModel = Field( + default_factory=ValidateAttributesModel, + title="Validate Attributes" + ) DEFAULT_PUBLISH_SETTINGS = { @@ -22,5 +50,9 @@ DEFAULT_PUBLISH_SETTINGS = { "enabled": True, "optional": True, "active": True - } + }, + "ValidateAttributes": { + "enabled": False, + "attributes": "{}" + }, } From 6a0decab459db0b83b777289521584f2eaca02a2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 27 Oct 2023 21:09:29 +0800 Subject: [PATCH 027/128] make sure to check invalid properties --- .../plugins/publish/validate_attributes.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index e98e73de06..2d3f09f972 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -29,10 +29,20 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not attributes: return - invalid_attributes = [key for key, value in attributes.items() - if rt.Execute(attributes[key]) != value] + for wrap_object, property_name in attributes.items(): + invalid_properties = [key for key in property_name.keys() + if not rt.Execute( + f'isProperty {wrap_object} "{key}"')] + if invalid_properties: + cls.log.error( + "Unknown Property Values:{}".format(invalid_properties)) + return invalid_properties + # TODO: support multiple varaible types in maxscript + invalid_attributes = [key for key, value in property_name.items() + if rt.Execute("{}.{}".format( + wrap_object, property_name[key]))!=value] - return invalid_attributes + return invalid_attributes def process(self, context): if not self.is_active(context.data): @@ -57,6 +67,10 @@ class ValidateAttributes(OptionalPyblishPluginMixin, context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) - invalid_attribute_keys = cls.get_invalid(context) - for key in invalid_attribute_keys: - attributes[key] = rt.Execute(attributes[key]) + for wrap_object, property_name in attributes.items(): + invalid_attributes = [key for key, value in property_name.items() + if rt.Execute("{}.{}".format( + wrap_object, property_name[key]))!=value] + for attrs in invalid_attributes: + rt.Execute("{}.{}={}".format( + wrap_object, attrs, attributes[wrap_object][attrs])) From c029fa632489529e36dbdaec5b74d2e938f46847 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 30 Oct 2023 17:21:29 +0800 Subject: [PATCH 028/128] support invalid checks on different variable types of attributes in Maxscript --- .../max/plugins/publish/validate_attributes.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 2d3f09f972..fa9912de07 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -38,9 +38,20 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "Unknown Property Values:{}".format(invalid_properties)) return invalid_properties # TODO: support multiple varaible types in maxscript - invalid_attributes = [key for key, value in property_name.items() - if rt.Execute("{}.{}".format( - wrap_object, property_name[key]))!=value] + invalid_attributes = [] + for key, value in property_name.items(): + property_key = rt.Execute("{}.{}".format( + wrap_object, key)) + if isinstance(value, str) and "#" not in value: + if property_key != '"{}"'.format(value): + invalid_attributes.append(key) + + elif isinstance(value, bool): + if property_key != value: + invalid_attributes.append(key) + else: + if property_key != '{}'.format(value): + invalid_attributes.append(key) return invalid_attributes @@ -71,6 +82,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, invalid_attributes = [key for key, value in property_name.items() if rt.Execute("{}.{}".format( wrap_object, property_name[key]))!=value] + for attrs in invalid_attributes: rt.Execute("{}.{}={}".format( wrap_object, attrs, attributes[wrap_object][attrs])) From 6c7e5c66a6b85e2ee8e42c2c9b38ea6639d9dd42 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 30 Oct 2023 21:13:42 +0800 Subject: [PATCH 029/128] support invalid checks on different variable types of attributes in Maxscript & repair actions --- .../plugins/publish/validate_attributes.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index fa9912de07..f266b2bca1 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -42,16 +42,16 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for key, value in property_name.items(): property_key = rt.Execute("{}.{}".format( wrap_object, key)) - if isinstance(value, str) and "#" not in value: - if property_key != '"{}"'.format(value): - invalid_attributes.append(key) - - elif isinstance(value, bool): - if property_key != value: - invalid_attributes.append(key) + if isinstance(value, str) and ( + value.startswith("#") and not value.endswith(")") + ): + # not applicable for #() array value type + # and only applicable for enum i.e. #bob, #sally + if "#{}".format(property_key) != value: + invalid_attributes.append((wrap_object, key)) else: - if property_key != '{}'.format(value): - invalid_attributes.append(key) + if property_key != value: + invalid_attributes.append((wrap_object, key)) return invalid_attributes @@ -62,12 +62,14 @@ class ValidateAttributes(OptionalPyblishPluginMixin, invalid_attributes = self.get_invalid(context) if invalid_attributes: bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid) for invalid in invalid_attributes + "- {}".format(invalid) for invalid + in invalid_attributes ) report = ( "Required Attribute(s) have invalid value(s).\n\n" f"{bullet_point_invalid_statement}\n\n" - "You can use repair action to fix it." + "You can use repair action to fix them if they are not\n" + "unknown property value(s)" ) raise PublishValidationError( report, title="Invalid Value(s) for Required Attribute(s)") @@ -78,11 +80,16 @@ class ValidateAttributes(OptionalPyblishPluginMixin, context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) - for wrap_object, property_name in attributes.items(): - invalid_attributes = [key for key, value in property_name.items() - if rt.Execute("{}.{}".format( - wrap_object, property_name[key]))!=value] - - for attrs in invalid_attributes: - rt.Execute("{}.{}={}".format( - wrap_object, attrs, attributes[wrap_object][attrs])) + invalid_attributes = cls.get_invalid(context) + for attrs in invalid_attributes: + prop, attr = attrs + value = attributes[prop][attr] + if isinstance(value, str) and not value.startswith("#"): + attribute_fix = '{}.{}="{}"'.format( + prop, attr, value + ) + else: + attribute_fix = "{}.{}={}".format( + prop, attr, value + ) + rt.Execute(attribute_fix) From f0b8d8d79826df7c27650dfbe68046e2d2d63d9d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:19:37 +0800 Subject: [PATCH 030/128] add docstrings and clean up the codes on the validator --- .../plugins/publish/validate_attributes.py | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index f266b2bca1..f603934eed 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -10,9 +10,47 @@ from openpype.pipeline.publish import ( ) +def has_property(object_name, property_name): + """Return whether an object has a property with given name""" + return rt.Execute(f'isProperty {object_name} "{property_name}"') + +def is_matching_value(object_name, property_name, value): + """Return whether an existing property matches value `value""" + property_value = rt.Execute(f"{object_name}.{property_name}") + + # Wrap property value if value is a string valued attributes + # starting with a `#` + if ( + isinstance(value, str) and + value.startswith("#") and + not value.endswith(")") + ): + # prefix value with `#` + # not applicable for #() array value type + # and only applicable for enum i.e. #bob, #sally + property_value = f"#{property_value}" + + return property_value == value + + class ValidateAttributes(OptionalPyblishPluginMixin, ContextPlugin): - """Validates attributes are consistent in 3ds max.""" + """Validates attributes in the project setting are consistent + with the nodes from MaxWrapper Class in 3ds max. + E.g. "renderers.current.separateAovFiles", + "renderers.production.PrimaryGIEngine" + Admin(s) need to put json below and enable this validator for a check: + { + "renderers.current":{ + "separateAovFiles" : True + } + "renderers.production":{ + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE", + } + .... + } + + """ order = ValidatorOrder hosts = ["max"] @@ -28,32 +66,24 @@ class ValidateAttributes(OptionalPyblishPluginMixin, ) if not attributes: return + invalid = [] + for object_name, required_properties in attributes.items(): + if not rt.Execute(f"isValidValue {object_name}"): + # Skip checking if the node does not + # exist in MaxWrapper Class + continue - for wrap_object, property_name in attributes.items(): - invalid_properties = [key for key in property_name.keys() - if not rt.Execute( - f'isProperty {wrap_object} "{key}"')] - if invalid_properties: - cls.log.error( - "Unknown Property Values:{}".format(invalid_properties)) - return invalid_properties - # TODO: support multiple varaible types in maxscript - invalid_attributes = [] - for key, value in property_name.items(): - property_key = rt.Execute("{}.{}".format( - wrap_object, key)) - if isinstance(value, str) and ( - value.startswith("#") and not value.endswith(")") - ): - # not applicable for #() array value type - # and only applicable for enum i.e. #bob, #sally - if "#{}".format(property_key) != value: - invalid_attributes.append((wrap_object, key)) - else: - if property_key != value: - invalid_attributes.append((wrap_object, key)) + for property_name, value in required_properties.items(): + if not has_property(object_name, property_name): + cls.log.error(f"Non-existing property: {object_name}.{property_name}") + invalid.append((object_name, property_name)) - return invalid_attributes + if not is_matching_value(object_name, property_name, value): + cls.log.error( + f"Invalid value for: {object_name}.{property_name}. Should be: {value}") + invalid.append((object_name, property_name)) + + return invalid def process(self, context): if not self.is_active(context.data): From 4c204a87a917ef05bf5b23e3f180d4f95650a3fb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:20:25 +0800 Subject: [PATCH 031/128] hound --- openpype/hosts/max/plugins/publish/validate_attributes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index f603934eed..44d6c64139 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -14,6 +14,7 @@ def has_property(object_name, property_name): """Return whether an object has a property with given name""" return rt.Execute(f'isProperty {object_name} "{property_name}"') + def is_matching_value(object_name, property_name, value): """Return whether an existing property matches value `value""" property_value = rt.Execute(f"{object_name}.{property_name}") From 33a21674c5e752c19be8636ab6587f38f91f8f59 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:21:37 +0800 Subject: [PATCH 032/128] hound --- openpype/hosts/max/plugins/publish/validate_attributes.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 44d6c64139..5697237c95 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -76,12 +76,14 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for property_name, value in required_properties.items(): if not has_property(object_name, property_name): - cls.log.error(f"Non-existing property: {object_name}.{property_name}") + cls.log.error( + f"Non-existing property: {object_name}.{property_name}") invalid.append((object_name, property_name)) if not is_matching_value(object_name, property_name, value): cls.log.error( - f"Invalid value for: {object_name}.{property_name}. Should be: {value}") + f"Invalid value for: {object_name}.{property_name}" + f". Should be: {value}") invalid.append((object_name, property_name)) return invalid From ce80ca2397c7ed9b609f0358a9c0595289a8e260 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 16:10:22 +0800 Subject: [PATCH 033/128] debug message --- openpype/hosts/max/plugins/publish/validate_attributes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 5697237c95..00b9d34c06 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -72,6 +72,8 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not rt.Execute(f"isValidValue {object_name}"): # Skip checking if the node does not # exist in MaxWrapper Class + cls.log.debug(f"Unable to find '{object_name}'." + f" Skipping validation of attributes") continue for property_name, value in required_properties.items(): From 3218b8064cdd00f7efab87bab935e1c8cb130c16 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 16:11:57 +0800 Subject: [PATCH 034/128] hound & docstring tweak --- openpype/hosts/max/plugins/publish/validate_attributes.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 00b9d34c06..75d3f05d07 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -46,7 +46,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "separateAovFiles" : True } "renderers.production":{ - "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE", + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" } .... } @@ -79,7 +79,8 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for property_name, value in required_properties.items(): if not has_property(object_name, property_name): cls.log.error( - f"Non-existing property: {object_name}.{property_name}") + "Non-existing property: " + f"{object_name}.{property_name}") invalid.append((object_name, property_name)) if not is_matching_value(object_name, property_name, value): From 009cda005227158561d898aa59c28ca16ae4166c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:18:06 +0800 Subject: [PATCH 035/128] update the debug message with dots --- openpype/hosts/max/plugins/publish/validate_attributes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 75d3f05d07..172b65e955 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -73,7 +73,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, # Skip checking if the node does not # exist in MaxWrapper Class cls.log.debug(f"Unable to find '{object_name}'." - f" Skipping validation of attributes") + " Skipping validation of attributes.") continue for property_name, value in required_properties.items(): @@ -86,7 +86,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not is_matching_value(object_name, property_name, value): cls.log.error( f"Invalid value for: {object_name}.{property_name}" - f". Should be: {value}") + f" Should be: {value}") invalid.append((object_name, property_name)) return invalid @@ -105,7 +105,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "Required Attribute(s) have invalid value(s).\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to fix them if they are not\n" - "unknown property value(s)" + "unknown property value(s)." ) raise PublishValidationError( report, title="Invalid Value(s) for Required Attribute(s)") From ad0b941475c67196ad7e09beada2bd81b2d51a63 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:28:42 +0800 Subject: [PATCH 036/128] lowercase invalid msg for the condition of not is_maching_value function --- openpype/hosts/max/plugins/publish/validate_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 172b65e955..0cd405aebd 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -86,7 +86,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not is_matching_value(object_name, property_name, value): cls.log.error( f"Invalid value for: {object_name}.{property_name}" - f" Should be: {value}") + f" should be: {value}") invalid.append((object_name, property_name)) return invalid From 371f9a52755a6c761b87ff1e6a07fe59384acbc1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 31 Oct 2023 11:49:17 +0100 Subject: [PATCH 037/128] Bugfix: Collect Rendered Files only collecting first instance (#5832) * Bugfix: Collect all instances from the metadata file - don't return on first iteration * Fix initial state of variable --- .../plugins/publish/collect_rendered_files.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 8a5a5a83f1..a249b3acda 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -56,6 +56,17 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): data_object["stagingDir"] = anatomy.fill_root(staging_dir) def _process_path(self, data, anatomy): + """Process data of a single JSON publish metadata file. + + Args: + data: The loaded metadata from the JSON file + anatomy: Anatomy for the current context + + Returns: + bool: Whether any instance of this particular metadata file + has a persistent staging dir. + + """ # validate basic necessary data data_err = "invalid json file - missing data" required = ["asset", "user", "comment", @@ -89,6 +100,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ["FTRACK_SERVER"] = ftrack["FTRACK_SERVER"] # now we can just add instances from json file and we are done + any_staging_dir_persistent = False for instance_data in data.get("instances"): self.log.debug(" - processing instance for {}".format( @@ -106,6 +118,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): staging_dir_persistent = instance.data.get( "stagingDir_persistent", False ) + if staging_dir_persistent: + any_staging_dir_persistent = True + representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) @@ -127,7 +142,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.debug( f"Adding audio to instance: {instance.data['audio']}") - return staging_dir_persistent + return any_staging_dir_persistent def process(self, context): self._context = context From e39689ba8e1a7a9de754ecf5becb487d5fda7665 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:28:15 +0800 Subject: [PATCH 038/128] add docs --- website/docs/artist_hosts_3dsmax.md | 24 ++++++++++++++++++ .../assets/3dsmax_validate_attributes.png | Bin 0 -> 35154 bytes 2 files changed, 24 insertions(+) create mode 100644 website/docs/assets/3dsmax_validate_attributes.png diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index fffab8ca5d..bc79094746 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -118,4 +118,28 @@ Current OpenPype integration (ver 3.15.0) supports only ```PointCache```, ```Ca This part of documentation is still work in progress. ::: +## Validators + +Current Openpype integration supports different validators such as Frame Range and Attributes. +Some validators are mandatory while some are optional and user can choose to enable them in the setting. + +**Validate Frame Range**: Optional Validator for checking Frame Range + +**Validate Attributes**: Optional Validator for checking if object properties' attributes are valid + in MaxWrapper Class. +:::note + Users can write the properties' attributes they want to check in dict format in the setting + before validation. + E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` + User can put the attributes in the dict format below + ``` + { + "renderer.current":{ + "separateAovFiles" : True + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" + } + } + ``` + ![Validate Attribute Setting](assets/3dsmax_validate_attributes.png) +::: ## ...to be added diff --git a/website/docs/assets/3dsmax_validate_attributes.png b/website/docs/assets/3dsmax_validate_attributes.png new file mode 100644 index 0000000000000000000000000000000000000000..5af82361887f47fda96607886ec10e15eb355df1 GIT binary patch literal 35154 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 FpM9ou89oV;b5Eca*vLo#<6{~%iTY)O)Z@( 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 sPpdcV0Zhn($&0bNe4%m4rY literal 0 HcmV?d00001 From 1795d501d995f037af9ad81d8731f14b67a7f19e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:29:34 +0800 Subject: [PATCH 039/128] add docs --- website/docs/artist_hosts_3dsmax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index bc79094746..6f23a19103 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -129,7 +129,7 @@ Some validators are mandatory while some are optional and user can choose to ena in MaxWrapper Class. :::note Users can write the properties' attributes they want to check in dict format in the setting - before validation. + before validation. The attributes are then to be converted into Maxscript and do a check. E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` User can put the attributes in the dict format below ``` From cfd9f0f06c26c0d47340f9baf42239674e2cebc8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:32:28 +0800 Subject: [PATCH 040/128] edit docstring --- openpype/hosts/max/plugins/publish/validate_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 0cd405aebd..0632ee38f0 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -40,11 +40,11 @@ class ValidateAttributes(OptionalPyblishPluginMixin, with the nodes from MaxWrapper Class in 3ds max. E.g. "renderers.current.separateAovFiles", "renderers.production.PrimaryGIEngine" - Admin(s) need to put json below and enable this validator for a check: + Admin(s) need to put the dict below and enable this validator for a check: { "renderers.current":{ "separateAovFiles" : True - } + }, "renderers.production":{ "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" } From 00fb722089a80dae83fe89b387ddcc481053f053 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 13:55:22 +0100 Subject: [PATCH 041/128] use AYON username for user in template data --- openpype/lib/local_settings.py | 6 ++++++ openpype/plugins/publish/collect_current_pype_user.py | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index dae6e074af..9b780fd88a 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -611,6 +611,12 @@ def get_openpype_username(): settings and last option is to use `getpass.getuser()` which returns machine username. """ + + if AYON_SERVER_ENABLED: + import ayon_api + + return ayon_api.get_user()["name"] + username = os.environ.get("OPENPYPE_USERNAME") if not username: local_settings = get_local_settings() diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index 2d507ba292..5c0c4fc82e 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -1,4 +1,6 @@ import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.lib import get_openpype_username @@ -7,7 +9,11 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): # Order must be after default pyblish-base CollectCurrentUser order = pyblish.api.CollectorOrder + 0.001 - label = "Collect Pype User" + label = ( + "Collect AYON User" + if AYON_SERVER_ENABLED + else "Collect OpenPype User" + ) def process(self, context): user = get_openpype_username() From 98f91ce932c4b544f0cb6d54f6010f8d99d493c4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 31 Oct 2023 15:37:15 +0100 Subject: [PATCH 042/128] Fix typo `actions_dir` -> `path` to fix register launcher actions from OpenPypeModule --- openpype/modules/launcher_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index 5e14f25f76..4f0674c94f 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -40,7 +40,7 @@ class LauncherAction(OpenPypeModule, ITrayAction): actions_paths = self.manager.collect_plugin_paths()["actions"] for path in actions_paths: if path and os.path.exists(path): - register_launcher_action_path(actions_dir) + register_launcher_action_path(path) paths_str = os.environ.get("AVALON_ACTIONS") or "" if paths_str: From 711976e68586a6110b04a0b7d650effca08dcd30 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 31 Oct 2023 18:05:20 +0200 Subject: [PATCH 043/128] fix bug when loading shelf files --- openpype/hosts/houdini/api/shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4b5ebd4202..0afc737665 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -44,7 +44,7 @@ def generate_shelves(): "{}".format(shelf_set_os_filepath)) continue - hou.shelves.newShelfSet(file_path=shelf_set_os_filepath) + hou.shelves.loadFile(shelf_set_os_filepath) continue shelf_set_name = shelf_set_config.get('shelf_set_name') From dd070c6fcc0ded177fefebcf64f5272bb4d007d3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 31 Oct 2023 18:06:06 +0200 Subject: [PATCH 044/128] Enhance shelves settings visual appeal --- .../schemas/schema_houdini_scriptshelf.json | 24 ++++++++++++------- .../houdini/server/settings/shelves.py | 14 +++++++---- server_addon/houdini/server/version.py | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index 35d768843d..f45377c8b4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -7,22 +7,30 @@ "object_type": { "type": "dict", "children": [ + { + "type": "label", + "label": "Option 1: Add a .shelf file" + }, + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + }, + { + "type": "label", + "label": "OR Option 2: Add Shelf Set Name and Shelves Definitions" + }, { "type": "text", "key": "shelf_set_name", "label": "Shelf Set Name" }, - { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path (optional)", - "multipath": false, - "multiplatform": true - }, { "type": "list", "key": "shelf_definition", - "label": "Shelves", + "label": "Shelves Definitions", "use_label_wrap": true, "object_type": { "type": "dict", diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 8d0512bdeb..e02ddf1c34 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -24,14 +24,18 @@ class ShelfDefinitionModel(BaseSettingsModel): class ShelvesModel(BaseSettingsModel): _layout = "expanded" - shelf_set_name: str = Field("", title="Shelfs set name") - shelf_set_source_path: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, - title="Shelf Set Path (optional)" + title="Shelf Set Path", + section="Option 1: Add a .shelf file." + ) + shelf_set_name: str = Field( + "", + title="Shelf Set Name", + section=("OR Option 2: Add Shelf Set Name " + "and Shelves Definitions.") ) - shelf_definition: list[ShelfDefinitionModel] = Field( default_factory=list, - title="Shelf Definitions" + title="Shelves Definitions" ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 01ef12070d..6cd38b7465 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.7" From 918770f817e5ab1a1c930bae81a441609b4d5ce2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 18:57:28 +0100 Subject: [PATCH 045/128] 'get_current_context_template_data' returns same values as base function 'get_template_data' --- openpype/pipeline/context_tools.py | 93 +++++++----------------------- 1 file changed, 22 insertions(+), 71 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 13630ae7ca..5afdb30f7b 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -25,10 +25,7 @@ from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy -from .template_data import ( - get_template_data_with_names, - get_template_data -) +from .template_data import get_template_data_with_names from .workfile import ( get_workfile_template_key, get_custom_workfile_template_by_string_context, @@ -483,6 +480,27 @@ def get_template_data_from_session(session=None, system_settings=None): ) +def get_current_context_template_data(system_settings=None): + """Prepare template data for current context. + + Args: + system_settings (Optional[Dict[str, Any]]): Prepared system settings. + + Returns: + Dict[str, Any] Template data for current context. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + return get_template_data_with_names( + project_name, asset_name, task_name, host_name, system_settings + ) + + def get_workdir_from_session(session=None, template_key=None): """Template data for template fill from session keys. @@ -661,70 +679,3 @@ def get_process_id(): if _process_id is None: _process_id = str(uuid.uuid4()) return _process_id - - -def get_current_context_template_data(): - """Template data for template fill from current context - - Returns: - Dict[str, Any] of the following tokens and their values - Supported Tokens: - - Regular Tokens - - app - - user - - asset - - parent - - hierarchy - - folder[name] - - root[work, ...] - - studio[code, name] - - project[code, name] - - task[type, name, short] - - - Context Specific Tokens - - assetData[frameStart] - - assetData[frameEnd] - - assetData[handleStart] - - assetData[handleEnd] - - assetData[frameStartHandle] - - assetData[frameEndHandle] - - assetData[resolutionHeight] - - assetData[resolutionWidth] - - """ - - # pre-prepare get_template_data args - current_context = get_current_context() - project_name = current_context["project_name"] - asset_name = current_context["asset_name"] - anatomy = Anatomy(project_name) - - # prepare get_template_data args - project_doc = get_project(project_name) - asset_doc = get_asset_by_name(project_name, asset_name) - task_name = current_context["task_name"] - host_name = get_current_host_name() - - # get regular template data - template_data = get_template_data( - project_doc, asset_doc, task_name, host_name - ) - - template_data["root"] = anatomy.roots - - # get context specific vars - asset_data = asset_doc["data"].copy() - - # compute `frameStartHandle` and `frameEndHandle` - if "frameStart" in asset_data and "handleStart" in asset_data: - asset_data["frameStartHandle"] = \ - asset_data["frameStart"] - asset_data["handleStart"] - - if "frameEnd" in asset_data and "handleEnd" in asset_data: - asset_data["frameEndHandle"] = \ - asset_data["frameEnd"] + asset_data["handleEnd"] - - # add assetData - template_data["assetData"] = asset_data - - return template_data From 4a11eed09ba8936351ce8acf1fa06fdd0ef904fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 18:59:06 +0100 Subject: [PATCH 046/128] implemented new function which does what houdini requires --- openpype/hosts/houdini/api/lib.py | 56 +++++++++++++++++++++++---- openpype/hosts/houdini/api/shelves.py | 5 ++- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index cadeaa8ed4..ac375c56d6 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -11,20 +11,21 @@ import json import six from openpype.lib import StringTemplate -from openpype.client import get_asset_by_name +from openpype.client import get_project, get_asset_by_name from openpype.settings import get_current_project_settings from openpype.pipeline import ( + Anatomy, get_current_project_name, get_current_asset_name, - registered_host -) -from openpype.pipeline.context_tools import ( - get_current_context_template_data, - get_current_project_asset + registered_host, + get_current_context, + get_current_host_name, ) +from openpype.pipeline.create import CreateContext +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.context_tools import get_current_project_asset from openpype.widgets import popup from openpype.tools.utils.host_tools import get_tool_by_name -from openpype.pipeline.create import CreateContext import hou @@ -804,6 +805,45 @@ def get_camera_from_container(container): return cameras[0] +def get_current_context_template_data_with_asset_data(): + """ + TODOs: + Support both 'assetData' and 'folderData' in future. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + anatomy = Anatomy(project_name) + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + + # get context specific vars + asset_data = asset_doc["data"] + + # compute `frameStartHandle` and `frameEndHandle` + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + handle_start = asset_data.get("handleStart") + handle_end = asset_data.get("handleEnd") + if frame_start is not None and handle_start is not None: + asset_data["frameStartHandle"] = frame_start - handle_start + + if frame_end is not None and handle_end is not None: + asset_data["frameEndHandle"] = frame_end + handle_end + + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + template_data["root"] = anatomy.roots + template_data["assetData"] = asset_data + + return template_data + + def get_context_var_changes(): """get context var changes.""" @@ -823,7 +863,7 @@ def get_context_var_changes(): return houdini_vars_to_update # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() # Set Houdini Vars for item in houdini_vars: diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4b5ebd4202..5df45a1f72 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -7,10 +7,11 @@ from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name from openpype.lib import StringTemplate -from openpype.pipeline.context_tools import get_current_context_template_data import hou +from .lib import get_current_context_template_data_with_asset_data + log = logging.getLogger("openpype.hosts.houdini.shelves") @@ -30,7 +31,7 @@ def generate_shelves(): return # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() for shelf_set_config in shelves_set_config: shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') From 9e85cc17a989fa88d4df8a5a8644ccb30470270f Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 1 Nov 2023 03:25:26 +0000 Subject: [PATCH 047/128] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index d6839c9b70..4865fcfb31 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.1" +__version__ = "3.17.5-nightly.2" From 15414809828905c24f89ce67547b101817d1309d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Nov 2023 03:26:11 +0000 Subject: [PATCH 048/128] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 73505368dd..249da3da0e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 - 3.17.4-nightly.2 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.4 - 3.15.1-nightly.3 - 3.15.1-nightly.2 - - 3.15.1-nightly.1 validations: required: true - type: dropdown From f330f87993d6826b519c7ee949c8c1a2c21e197b Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 10:37:41 +0200 Subject: [PATCH 049/128] Add options to Houdini shelves manager settings --- .../schemas/schema_houdini_scriptshelf.json | 141 +++++++++--------- .../houdini/server/settings/shelves.py | 11 ++ 2 files changed, 85 insertions(+), 67 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index f45377c8b4..2dfce906b7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -5,78 +5,85 @@ "is_group": true, "use_label_wrap": true, "object_type": { - "type": "dict", - "children": [ + "type": "dict-conditional", + "enum_key": "options", + "enum_label": "Options", + "enum_children": [ { - "type": "label", - "label": "Option 1: Add a .shelf file" + + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + } + ] }, { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path", - "multipath": false, - "multiplatform": true - }, - { - "type": "label", - "label": "OR Option 2: Add Shelf Set Name and Shelves Definitions" - }, - { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves Definitions", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "label", - "label": "Name and Script Path are mandatory." - }, - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] } - ] - } + } + ] } - ] - } + } + ] } ] } diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index e02ddf1c34..651af27537 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -21,9 +21,20 @@ class ShelfDefinitionModel(BaseSettingsModel): title="Shelf Tools" ) +def shelves_enum_options(): + return [ + {"value": "add_shelf_file", "label": "Add a .shelf file"}, + {"value": "add_set_and_definitions", "label": "Add Shelf Set Name and Shelves Definitions"} + ] class ShelvesModel(BaseSettingsModel): _layout = "expanded" + options: str = Field( + title="Options", + description="Switch between shelves manager options", + enum_resolver=shelves_enum_options, + conditionalEnum=True + ) shelf_set_source_path: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, title="Shelf Set Path", From 0e331db93a0de805a55e88711d5d880f6281715e Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 11:24:48 +0200 Subject: [PATCH 050/128] Adjust Houdini Shelves Ayon settings --- .../houdini/server/settings/shelves.py | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 651af27537..a0acc90505 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -21,32 +21,47 @@ class ShelfDefinitionModel(BaseSettingsModel): title="Shelf Tools" ) + +class AddShelfFileModel(BaseSettingsModel): + shelf_set_source_path: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Shelf Set Path" + ) + + +class AddSetAndDefinitionsModel(BaseSettingsModel): + shelf_set_name: str = Field(title="Shelf Set Name") + shelf_definition: list[ShelfDefinitionModel] = Field( + default_factory=list, + title="Shelves Definitions" + ) + + def shelves_enum_options(): return [ - {"value": "add_shelf_file", "label": "Add a .shelf file"}, - {"value": "add_set_and_definitions", "label": "Add Shelf Set Name and Shelves Definitions"} + { + "value": "add_shelf_file", + "label": "Add a .shelf file" + }, + { + "value": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions" + } ] + class ShelvesModel(BaseSettingsModel): - _layout = "expanded" options: str = Field( title="Options", description="Switch between shelves manager options", enum_resolver=shelves_enum_options, conditionalEnum=True ) - shelf_set_source_path: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Shelf Set Path", - section="Option 1: Add a .shelf file." + add_shelf_file: AddShelfFileModel = Field( + title="Add a .shelf file", + default_factory=AddShelfFileModel ) - shelf_set_name: str = Field( - "", - title="Shelf Set Name", - section=("OR Option 2: Add Shelf Set Name " - "and Shelves Definitions.") - ) - shelf_definition: list[ShelfDefinitionModel] = Field( - default_factory=list, - title="Shelves Definitions" + add_set_and_definitions: AddSetAndDefinitionsModel = Field( + title="Add Shelf Set Name and Shelves Definitions", + default_factory=AddSetAndDefinitionsModel ) From 381c00c3342193442e46b3e2475db64dda129beb Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 12:29:41 +0200 Subject: [PATCH 051/128] Align Openpype and Ayon settings, quick fix in shelves loader script --- openpype/hosts/houdini/api/shelves.py | 34 +++-- .../schemas/schema_houdini_scriptshelf.json | 128 ++++++++++-------- .../houdini/server/settings/shelves.py | 2 +- 3 files changed, 91 insertions(+), 73 deletions(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 0afc737665..6fb3967be8 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -23,29 +23,33 @@ def generate_shelves(): # load configuration of houdini shelves project_name = get_current_project_name() project_settings = get_project_settings(project_name) - shelves_set_config = project_settings["houdini"]["shelves"] + shelves_configs = project_settings["houdini"]["shelves"] - if not shelves_set_config: + if not shelves_configs: log.debug("No custom shelves found in project settings.") return # Get Template data template_data = get_current_context_template_data() - for shelf_set_config in shelves_set_config: - shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') - shelf_set_os_filepath = shelf_set_filepath[current_os] - if shelf_set_os_filepath: - shelf_set_os_filepath = get_path_using_template_data( - shelf_set_os_filepath, template_data - ) - if not os.path.isfile(shelf_set_os_filepath): - log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) - continue + for config in shelves_configs: + selected_option = config["options"] + shelf_set_config = config[selected_option] - hou.shelves.loadFile(shelf_set_os_filepath) - continue + shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') + if shelf_set_filepath: + shelf_set_os_filepath = shelf_set_filepath[current_os] + if shelf_set_os_filepath: + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) + if not os.path.isfile(shelf_set_os_filepath): + log.error("Shelf path doesn't exist - " + "{}".format(shelf_set_os_filepath)) + continue + + hou.shelves.loadFile(shelf_set_os_filepath) + continue shelf_set_name = shelf_set_config.get('shelf_set_name') if not shelf_set_name: diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index 2dfce906b7..cee04b73e5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -15,11 +15,18 @@ "label": "Add a .shelf file", "children": [ { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path", - "multipath": false, - "multiplatform": true + "type": "dict", + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + } + ] } ] }, @@ -28,60 +35,67 @@ "label": "Add Shelf Set Name and Shelves Definitions", "children": [ { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves Definitions", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "label", - "label": "Name and Script Path are mandatory." - }, - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] } - ] - } + } + ] } - ] - } + } + ] } ] } diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index a0acc90505..133c18f77c 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -30,7 +30,7 @@ class AddShelfFileModel(BaseSettingsModel): class AddSetAndDefinitionsModel(BaseSettingsModel): - shelf_set_name: str = Field(title="Shelf Set Name") + shelf_set_name: str = Field("", title="Shelf Set Name") shelf_definition: list[ShelfDefinitionModel] = Field( default_factory=list, title="Shelves Definitions" From c185d2cab8b9cb1a21d17c26acfeb8180eedcb15 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 12:35:41 +0200 Subject: [PATCH 052/128] resolve hound --- openpype/hosts/houdini/api/shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 8b27ecd67e..5093a90988 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -46,7 +46,7 @@ def generate_shelves(): ) if not os.path.isfile(shelf_set_os_filepath): log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) + "{}".format(shelf_set_os_filepath)) continue hou.shelves.loadFile(shelf_set_os_filepath) From 82f3b5e07f9d5b2bbe2a7679f11c52adf5ad51ff Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 1 Nov 2023 18:40:28 +0800 Subject: [PATCH 053/128] avoid using asset from context in collect render and also clean up unncessary code from the collector --- .../max/plugins/publish/collect_render.py | 19 +------------------ .../plugins/publish/collect_scene_version.py | 1 + 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7765b3b924..38194a0735 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,11 +4,9 @@ import os import pyblish.api from pymxs import runtime as rt -from openpype.pipeline import get_current_asset_name from openpype.hosts.max.api import colorspace from openpype.hosts.max.api.lib import get_max_version, get_current_renderer from openpype.hosts.max.api.lib_renderproducts import RenderProducts -from openpype.client import get_last_version_by_subset_name class CollectRender(pyblish.api.InstancePlugin): @@ -27,7 +25,6 @@ class CollectRender(pyblish.api.InstancePlugin): filepath = current_file.replace("\\", "/") context.data['currentFile'] = current_file - asset = get_current_asset_name() files_by_aov = RenderProducts().get_beauty(instance.name) aovs = RenderProducts().get_aovs(instance.name) @@ -49,19 +46,6 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["files"].append(files_by_aov) img_format = RenderProducts().image_format() - project_name = context.data["projectName"] - asset_doc = context.data["assetEntity"] - asset_id = asset_doc["_id"] - version_doc = get_last_version_by_subset_name(project_name, - instance.name, - asset_id) - self.log.debug("version_doc: {0}".format(version_doc)) - version_int = 1 - if version_doc: - version_int += int(version_doc["name"]) - - self.log.debug(f"Setting {version_int} to context.") - context.data["version"] = version_int # OCIO config not support in # most of the 3dsmax renderers # so this is currently hard coded @@ -87,7 +71,7 @@ class CollectRender(pyblish.api.InstancePlugin): renderer = str(renderer_class).split(":")[0] # also need to get the render dir for conversion data = { - "asset": asset, + "asset": instance.data["asset"], "subset": str(instance.name), "publish": True, "maxversion": str(get_max_version()), @@ -99,7 +83,6 @@ class CollectRender(pyblish.api.InstancePlugin): "plugin": "3dsmax", "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], - "version": version_int, "farm": True } instance.data.update(data) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 7920c1e82b..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", From 424a0d6f2fdb9ab866aeb7bbf2c8d552e7a19a95 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 11:14:38 +0000 Subject: [PATCH 054/128] Fix missing grease pencils in thumbnails and playblasts --- openpype/hosts/blender/plugins/publish/collect_review.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 3bf2e39e24..2760ab9811 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -31,11 +31,12 @@ class CollectReview(pyblish.api.InstancePlugin): focal_length = cameras[0].data.lens - # get isolate objects list from meshes instance members . + # get isolate objects list from meshes instance members. + types = {"MESH", "GPENCIL"} isolate_objects = [ obj for obj in instance - if isinstance(obj, bpy.types.Object) and obj.type == "MESH" + if isinstance(obj, bpy.types.Object) and obj.type in types ] if not instance.data.get("remove"): From e4aa43e91bdc9e8f81055d7357bfb219cdd98a68 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 11:53:49 +0000 Subject: [PATCH 055/128] Fix Blender Render Settings in Ayon --- server_addon/blender/server/settings/main.py | 2 +- server_addon/blender/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index 4476ea709b..374b2fafa2 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -41,7 +41,7 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) - render_settings: RenderSettingsModel = Field( + RenderSettings: RenderSettingsModel = Field( default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" From 0916db5fa0c98e239e65ff5a95fa76184fec613f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 14:35:22 +0200 Subject: [PATCH 056/128] set f1 and f2 to $FSTART and $FEND respectively --- openpype/hosts/houdini/plugins/create/create_composite.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_composite.py b/openpype/hosts/houdini/plugins/create/create_composite.py index 9d4f7969bb..52ea6fa054 100644 --- a/openpype/hosts/houdini/plugins/create/create_composite.py +++ b/openpype/hosts/houdini/plugins/create/create_composite.py @@ -45,6 +45,11 @@ class CreateCompositeSequence(plugin.HoudiniCreator): instance_node.setParms(parms) + # Manually set f1 & f2 to $FSTART and $FEND respectively + # to match other Houdini nodes default. + instance_node.parm("f1").setExpression("$FSTART") + instance_node.parm("f2").setExpression("$FEND") + # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) From 41d9cf65b0d8d70affefe47e6feaca71c406a4d0 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:05:33 +0200 Subject: [PATCH 057/128] make tab menu name change according to the app whether OpenPype or AYON --- openpype/hosts/houdini/api/creator_node_shelves.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 1f9fef7417..085642a277 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -173,6 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() + tab_menu_label = os.environ.get("AVALON_LABEL") or "OpenPype" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins @@ -197,14 +198,14 @@ def install(): if not network_categories: continue - key = "openpype_create.{}".format(identifier) + key = "ayon_create.{}".format(identifier) log.debug(f"Registering {key}") script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, "icon": icon, - "help": "Create OpenPype publish instance for {}".format( + "help": "Create Ayon publish instance for {}".format( creator.label ), "help_url": None, @@ -213,7 +214,7 @@ def install(): "cop_viewer_categories": [], "network_op_type": None, "viewer_op_type": None, - "locations": ["OpenPype"] + "locations": [tab_menu_label] } label = "Create {}".format(creator.label) tool = hou.shelves.tool(key) From c577d2bc84854382ecf157b22f293d4f23c38298 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 13:09:57 +0000 Subject: [PATCH 058/128] Fix default settings --- server_addon/blender/server/settings/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index 374b2fafa2..5eff276ef5 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -61,7 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, - "render_settings": DEFAULT_RENDER_SETTINGS, + "RenderSettings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, From eb242e78a5afeef40c814bc27a140dbfdc0e7f85 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:13:40 +0200 Subject: [PATCH 059/128] add get_network_categories --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 8 +++++++- openpype/hosts/houdini/plugins/create/create_hda.py | 7 ++++++- .../houdini/plugins/create/create_redshift_proxy.py | 12 +++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index a3f31e7e94..0f629cf9c9 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -3,6 +3,7 @@ from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError from openpype.lib import EnumDef +import hou class CreateBGEO(plugin.HoudiniCreator): @@ -13,7 +14,6 @@ class CreateBGEO(plugin.HoudiniCreator): icon = "gears" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) @@ -90,3 +90,9 @@ class CreateBGEO(plugin.HoudiniCreator): return attrs + [ EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), ] + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index c4093bfbc6..ac075d2072 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -5,6 +5,7 @@ from openpype.client import ( get_subsets, ) from openpype.hosts.houdini.api import plugin +import hou class CreateHDA(plugin.HoudiniCreator): @@ -35,7 +36,6 @@ class CreateHDA(plugin.HoudiniCreator): def create_instance_node( self, node_name, parent, node_type="geometry"): - import hou parent_node = hou.node("/obj") if self.selected_nodes: @@ -81,3 +81,8 @@ class CreateHDA(plugin.HoudiniCreator): pre_create_data) # type: plugin.CreatedInstance return instance + + def get_network_categories(self): + return [ + hou.objNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py index b814dd9d57..3a4ab7008b 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating Redshift proxies.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance +import hou class CreateRedshiftProxy(plugin.HoudiniCreator): @@ -12,7 +12,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): icon = "magic" def create(self, subset_name, instance_data, pre_create_data): - import hou # noqa + # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) @@ -28,7 +28,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): instance = super(CreateRedshiftProxy, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) instance_node = hou.node(instance.get("instance_node")) @@ -44,3 +44,9 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): # Lock some Avalon attributes to_lock = ["family", "id", "prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] From aed9b13e0048951f9cc8605415ea7d5142ca11cb Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:15:09 +0200 Subject: [PATCH 060/128] update ids to 'ayon' instead of 'openpype' --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index b2e32a70f9..c875cac0f5 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -16,7 +16,7 @@ return label - + - + - + Date: Wed, 1 Nov 2023 15:35:09 +0200 Subject: [PATCH 061/128] update get_network_categories --- openpype/hosts/houdini/plugins/create/create_pointcache.py | 1 + openpype/hosts/houdini/plugins/create/create_staticmesh.py | 1 + openpype/hosts/houdini/plugins/create/create_vbd_cache.py | 1 + 3 files changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 7eaf2aff2b..8fe8052e0a 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -83,6 +83,7 @@ class CreatePointCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py index ea0b36f03f..d0985198bd 100644 --- a/openpype/hosts/houdini/plugins/create/create_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -54,6 +54,7 @@ class CreateStaticMesh(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index 9c96e48e3a..69418f9575 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -40,6 +40,7 @@ class CreateVDBCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] From 25c8a424ec65330777bc3968c41d9fe6f59d470c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:40:29 +0200 Subject: [PATCH 062/128] skip check if node has no 'trange' parameter --- .../hosts/houdini/plugins/publish/validate_frame_range.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 6a66f3de9f..2264372549 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -57,6 +57,14 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): return rop_node = hou.node(instance.data["instance_node"]) + + if rop_node.parm("trange") is None: + cls.log.debug( + "Skipping Check, Node has no 'trange' parameter: {}" + .format(rop_node.path()) + ) + return + if instance.data["frameStart"] > instance.data["frameEnd"]: cls.log.info( "The ROP node render range is set to " From 60438ab4a8bed39a8ee681f03e995e88a8b17943 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:00:41 +0200 Subject: [PATCH 063/128] BigRoy's comments - Better conditional and debug message --- .../houdini/plugins/publish/validate_frame_range.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 2264372549..5d3866cfdb 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -57,15 +57,17 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): return rop_node = hou.node(instance.data["instance_node"]) + frame_start = instance.data.get("frameStart") + frame_end = instance.data.get("frameEnd") - if rop_node.parm("trange") is None: + if not (frame_start or frame_end): cls.log.debug( - "Skipping Check, Node has no 'trange' parameter: {}" - .format(rop_node.path()) + "Skipping frame range validation for " + "instance without frame data: {}".format(rop_node.path()) ) return - if instance.data["frameStart"] > instance.data["frameEnd"]: + if frame_start > frame_end: cls.log.info( "The ROP node render range is set to " "{0[frameStartHandle]} - {0[frameEndHandle]} " From 5a873368ee9b2544ea2c19401354ec5f94712537 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:56:23 +0200 Subject: [PATCH 064/128] fix loading bug --- openpype/hosts/houdini/plugins/load/load_image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 663a93e48b..cff2b74e52 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -119,7 +119,8 @@ class ImageLoader(load.LoaderPlugin): if not parent.children(): parent.destroy() - def _get_file_sequence(self, root): + def _get_file_sequence(self, file_path): + root = os.path.dirname(file_path) files = sorted(os.listdir(root)) first_fname = files[0] From dca872e1fce9f1735063769d17e8256f9c003125 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:57:00 +0200 Subject: [PATCH 065/128] fix collector order to fix the missing frames --- openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_frames.py | 4 +++- openpype/hosts/houdini/plugins/publish/collect_karma_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py | 4 ++-- .../hosts/houdini/plugins/publish/collect_redshift_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_vray_rop.py | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index b489f83b29..420a8324fe 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -21,8 +21,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): label = "Arnold ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 01df809d4c..79cfcc6139 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -11,7 +11,9 @@ from openpype.hosts.houdini.api import lib class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" - order = pyblish.api.CollectorOrder + 0.01 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "bgeo"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index fe0b8711fc..a477529df9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -25,8 +25,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): label = "Karma ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index cc412f30a1..9f0ae8d33c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -25,8 +25,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): label = "Mantra ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index deb9eac971..0bd7b41641 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -25,8 +25,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): label = "Redshift ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 53072aebc6..519c12aede 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -25,8 +25,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): label = "VRay ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["vray_rop"] From c15adfa327cdb30f13c21f9e0f6c11d18c73ca9e Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 18:05:23 +0200 Subject: [PATCH 066/128] BigRoys' commit - fallback to AYON --- openpype/hosts/houdini/api/creator_node_shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 085642a277..14662dc419 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -173,7 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() - tab_menu_label = os.environ.get("AVALON_LABEL") or "OpenPype" + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins From c64af8ddef70412de33b8c04ff048cfbdc41d77d Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 18:06:36 +0200 Subject: [PATCH 067/128] BigRoy's comment - remove Obj from network_categories to avoid possible confusion --- openpype/hosts/houdini/plugins/create/create_pointcache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 8fe8052e0a..7eaf2aff2b 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -83,7 +83,6 @@ class CreatePointCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), - hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] From bd8638caa10524dca1554208d4b301413729983b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 1 Nov 2023 17:16:37 +0100 Subject: [PATCH 068/128] ftrack events are not processed if project is not available in OpenPype database --- .../event_first_version_status.py | 45 ++++++++++++++----- .../event_next_task_update.py | 6 +++ .../event_push_frame_values_to_task.py | 6 +++ .../event_task_to_parent_status.py | 6 +++ .../event_task_to_version_status.py | 6 +++ .../event_thumbnail_updates.py | 6 +++ .../event_version_to_task_statuses.py | 5 +++ 7 files changed, 70 insertions(+), 10 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py index 8ef333effd..2ac02f233e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py @@ -1,3 +1,6 @@ +import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -73,8 +76,21 @@ class FirstVersionStatus(BaseEvent): if not self.task_status_map: return - entities_info = self.filter_event_ents(event) - if not entities_info: + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return + + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) + + def process_by_project(self, session, event, project_id, entities_info): + project_name = self.get_project_name_from_event( + session, event, project_id + ) + if get_project(project_name) is None: + self.log.debug( + f"Project '{project_name}' not found in OpenPype. Skipping" + ) return entity_ids = [] @@ -154,18 +170,18 @@ class FirstVersionStatus(BaseEvent): exc_info=True ) - def filter_event_ents(self, event): - filtered_ents = [] - for entity in event["data"].get("entities", []): + def filter_entities_info(self, event): + filtered_entities_info = collections.defaultdict(list) + for entity_info in event["data"].get("entities", []): # Care only about add actions - if entity.get("action") != "add": + if entity_info.get("action") != "add": continue # Filter AssetVersions - if entity["entityType"] != "assetversion": + if entity_info["entityType"] != "assetversion": continue - entity_changes = entity.get("changes") or {} + entity_changes = entity_info.get("changes") or {} # Check if version of Asset Version is `1` version_num = entity_changes.get("version", {}).get("new") @@ -177,9 +193,18 @@ class FirstVersionStatus(BaseEvent): if not task_id: continue - filtered_ents.append(entity) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - return filtered_ents + if project_id is None: + continue + + filtered_entities_info[project_id].append(entity_info) + + return filtered_entities_info def register(session): diff --git a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py index a100c34f67..07a8ff433e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py +++ b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -99,6 +101,10 @@ class NextTaskUpdate(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index ed630ad59d..65c3c1a69a 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -3,6 +3,8 @@ import copy from typing import Any import ftrack_api + +from openpype.client import get_project from openpype_modules.ftrack.lib import ( BaseEvent, query_custom_attributes, @@ -139,6 +141,10 @@ class PushHierValuesToNonHierEvent(BaseEvent): project_name: str = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return set(), set() + # Load settings project_settings: dict[str, Any] = ( self.get_project_settings_from_event(event, project_name) diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py index 25fa3b0535..d2b395a1a3 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -60,6 +62,10 @@ class TaskStatusToParent(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py index b77849c678..91ee2410d7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -102,6 +104,10 @@ class TaskToVersionStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py index 64673f792c..318e69f414 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py +++ b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -22,6 +24,10 @@ class ThumbnailEvents(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index fb40fd6417..fbe44bcba7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -1,3 +1,4 @@ +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -50,6 +51,10 @@ class VersionToTaskStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name From d4b75797c69a59725e3d8348f44aefedb5136455 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 20:49:05 +0100 Subject: [PATCH 069/128] nuke: making sure duplicated loader is not removed --- openpype/hosts/nuke/api/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index c39e3c339d..301b9533a9 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -537,6 +537,7 @@ class NukeLoader(LoaderPlugin): node.addKnob(knob) def clear_members(self, parent_node): + parent_class = parent_node.Class() members = self.get_members(parent_node) dependent_nodes = None @@ -549,6 +550,8 @@ class NukeLoader(LoaderPlugin): break for member in members: + if member.Class() == parent_class: + continue self.log.info("removing node: `{}".format(member.name())) nuke.delete(member) From e3eea5a8e35fedd12bebfdc0da77338850b22457 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 20:49:36 +0100 Subject: [PATCH 070/128] Nuke: updating without node renameing --- openpype/hosts/nuke/plugins/load/load_clip.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 19038b168d..737da3746d 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -299,9 +299,6 @@ class LoadClip(plugin.NukeLoader): "Representation id `{}` is failing to load".format(repre_id)) return - read_name = self._get_node_name(representation) - - read_node["name"].setValue(read_name) read_node["file"].setValue(filepath) # to avoid multiple undo steps for rest of process From 83798a7b5e3daa3c71bb34d0849e70817730b311 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 22:15:41 +0200 Subject: [PATCH 071/128] BigRoy's comment - fallback to 'AYON' --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ac375c56d6..db7f0886c3 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1018,7 +1018,7 @@ def self_publish(): def add_self_publish_button(node): """Adds a self publish button to the rop node.""" - label = os.environ.get("AVALON_LABEL") or "OpenPype" + label = os.environ.get("AVALON_LABEL") or "AYON" button_parm = hou.ButtonParmTemplate( "ayon_self_publish", From 3a78230ba4c8fc69285d9b660499891245479765 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 22:16:57 +0200 Subject: [PATCH 072/128] BigRoy's comment - fallback to 'AYON' --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index c875cac0f5..0903aef7bc 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -4,7 +4,7 @@ Date: Wed, 1 Nov 2023 22:20:42 +0200 Subject: [PATCH 073/128] BigRoy's comment - Update COnditional --- openpype/hosts/houdini/plugins/publish/validate_frame_range.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 5d3866cfdb..90a079217b 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -60,7 +60,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): frame_start = instance.data.get("frameStart") frame_end = instance.data.get("frameEnd") - if not (frame_start or frame_end): + if frame_start is None or frame_end is None: cls.log.debug( "Skipping frame range validation for " "instance without frame data: {}".format(rop_node.path()) From 978ec89f6562c82d2cb9d878cc3563448a21aca3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 21:35:20 +0100 Subject: [PATCH 074/128] Nuke: updating ls method to have full name and node --- openpype/hosts/nuke/api/pipeline.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index a1d290646c..f6ba33f00f 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -478,8 +478,6 @@ def parse_container(node): """ data = read_avalon_data(node) - # (TODO) Remove key validation when `ls` has re-implemented. - # # If not all required data return the empty container required = ["schema", "id", "name", "namespace", "loader", "representation"] @@ -487,7 +485,10 @@ def parse_container(node): return # Store the node's name - data["objectName"] = node["name"].value() + data.update({ + "objectName": node.fullName(), + "node": node, + }) return data From 16aad9928823a10034d6d9bfc1ba7cb25fd24a53 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 21:36:48 +0100 Subject: [PATCH 075/128] nuke: updating loaders so they are using nodes rather then objectName --- .../hosts/nuke/plugins/load/load_backdrop.py | 16 ++++---- .../nuke/plugins/load/load_camera_abc.py | 24 ++++++----- openpype/hosts/nuke/plugins/load/load_clip.py | 6 +-- .../hosts/nuke/plugins/load/load_effects.py | 30 +++++++------- .../nuke/plugins/load/load_effects_ip.py | 40 ++++++++----------- .../hosts/nuke/plugins/load/load_gizmo.py | 28 +++++++------ .../hosts/nuke/plugins/load/load_gizmo_ip.py | 28 +++++++------ .../hosts/nuke/plugins/load/load_image.py | 6 +-- .../hosts/nuke/plugins/load/load_model.py | 29 ++++++++------ .../hosts/nuke/plugins/load/load_ociolook.py | 25 +++++------- .../nuke/plugins/load/load_script_precomp.py | 7 +--- 11 files changed, 117 insertions(+), 122 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 0cbd380697..54d37da203 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -64,8 +64,7 @@ class LoadBackdropNodes(load.LoaderPlugin): data_imprint = { "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name + "colorspaceInput": colorspace } for k in add_keys: @@ -194,7 +193,7 @@ class LoadBackdropNodes(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") @@ -207,10 +206,11 @@ class LoadBackdropNodes(load.LoaderPlugin): add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -252,6 +252,6 @@ class LoadBackdropNodes(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index e245b0cb5e..898c5e4e7b 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -48,10 +48,11 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,7 +112,7 @@ class AlembicCameraLoader(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + object_name = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -124,11 +125,12 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -194,6 +196,6 @@ class AlembicCameraLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 737da3746d..3a2ec3dbee 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -189,8 +189,6 @@ class LoadClip(plugin.NukeLoader): value_ = value_.replace("\\", "/") data_imprint[key] = value_ - data_imprint["objectName"] = read_name - if add_retime and version_data.get("retime", None): data_imprint["addRetime"] = True @@ -254,7 +252,7 @@ class LoadClip(plugin.NukeLoader): is_sequence = len(representation["files"]) > 1 - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] if is_sequence: representation = self._representation_with_hash_in_frame( @@ -353,7 +351,7 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index cacc00854e..cc048372d4 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -62,11 +62,12 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -159,7 +160,7 @@ class LoadEffects(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -175,12 +176,13 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -212,7 +214,7 @@ class LoadEffects(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -346,6 +348,6 @@ class LoadEffects(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index bdf3cd6965..cdfdfef3b8 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -63,11 +63,12 @@ class LoadEffectsInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -98,7 +99,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -164,28 +165,26 @@ class LoadEffectsInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") - name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) - namespace = container['namespace'] colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -217,7 +216,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -251,11 +250,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): output = nuke.createNode("Output") output.setInput(0, pre_node) - # # try to place it under Viewer1 - # if not self.connect_active_viewer(GN): - # nuke.delete(GN) - # return - # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -365,6 +359,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 23cf4d7741..19b5cca74e 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -64,11 +64,12 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,7 +112,7 @@ class LoadGizmo(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -126,12 +127,13 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -175,6 +177,6 @@ class LoadGizmo(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index ce0a1615f1..5b4877678a 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -66,11 +66,12 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -118,7 +119,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -133,12 +134,13 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -256,6 +258,6 @@ class LoadGizmoInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 6bffb97e6f..411a61d77b 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -146,8 +146,6 @@ class LoadImage(load.LoaderPlugin): data_imprint.update( {k: context["version"]['data'].get(k, str(None))}) - data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) return containerise(r, @@ -168,7 +166,7 @@ class LoadImage(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container["objectName"]) + node = container["node"] frame_number = node["first"].value() assert node.Class() == "Read", "Must be Read" @@ -237,7 +235,7 @@ class LoadImage(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] assert node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index b9b8a0f4c0..3fe92b74d0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -46,10 +46,11 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -114,9 +115,9 @@ class AlembicModelLoader(load.LoaderPlugin): # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + # get corresponding node - model_node = nuke.toNode(object_name) + model_node = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -129,11 +130,12 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -142,7 +144,6 @@ class AlembicModelLoader(load.LoaderPlugin): file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): - model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) # collect input output dependencies @@ -163,8 +164,10 @@ class AlembicModelLoader(load.LoaderPlugin): ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) + + # paste the node back and set the position nuke.nodePaste("%clipboard%") - model_node = nuke.toNode(object_name) + model_node = nuke.selectedNode() model_node.setXYpos(xpos, ypos) # link to original input nodes diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 18c8cdba35..c0f8235253 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -55,7 +55,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): """ namespace = namespace or context['asset']['name'] suffix = secrets.token_hex(nbytes=4) - object_name = "{}_{}_{}".format( + node_name = "{}_{}_{}".format( name, namespace, suffix) # getting file path @@ -64,7 +64,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, json_f["data"]) + filepath, json_f["data"]) + # renaming group node + group_node["name"].setValue(node_name) self._node_version_color(context["version"], group_node) @@ -76,17 +78,14 @@ class LoadOcioLookNodes(load.LoaderPlugin): name=name, namespace=namespace, context=context, - loader=self.__class__.__name__, - data={ - "objectName": object_name, - } + loader=self.__class__.__name__ ) def _create_group_node( self, - object_name, filepath, - data + data, + group_node=None ): """Creates group node with all the nodes inside. @@ -94,9 +93,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): in between - in case those are needed. Arguments: - object_name (str): name of the group node filepath (str): path to json file data (dict): data from json file + group_node (Optional[nuke.Node]): group node or None Returns: nuke.Node: group node with all the nodes inside @@ -117,7 +116,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): input_node = None output_node = None - group_node = nuke.toNode(object_name) if group_node: # remove all nodes between Input and Output nodes for node in group_node.nodes(): @@ -130,7 +128,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): else: group_node = nuke.createNode( "Group", - "name {}_1".format(object_name), inpanel=False ) @@ -227,16 +224,16 @@ class LoadOcioLookNodes(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + group_node = container["node"] filepath = get_representation_path(representation) json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, - json_f["data"] + json_f["data"], + group_node ) self._node_version_color(version_doc, group_node) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index d5f9d24765..cbe19d217b 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -46,8 +46,6 @@ class LinkAsGroup(load.LoaderPlugin): file = self.filepath_from_context(context).replace("\\", "/") self.log.info("file: {}\n".format(file)) - precomp_name = context["representation"]["context"]["subset"] - self.log.info("versionData: {}\n".format(context["version"]["data"])) # add additional metadata from the version to imprint to Avalon knob @@ -62,7 +60,6 @@ class LinkAsGroup(load.LoaderPlugin): } for k in add_keys: data_imprint.update({k: context["version"]['data'][k]}) - data_imprint.update({"objectName": precomp_name}) # group context is set to precomp, so back up one level. nuke.endGroup() @@ -118,7 +115,7 @@ class LinkAsGroup(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container['objectName']) + node = container["node"] root = get_representation_path(representation).replace("\\", "/") @@ -159,6 +156,6 @@ class LinkAsGroup(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) From 8bf570ceef7823a2bf8615a30ac15dfc59c8eaf7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:24:25 +0800 Subject: [PATCH 076/128] up version for the max bundle --- server_addon/max/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From 3da4e1c8e7eafec01b4a501234c6a78cf7a1c686 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Nov 2023 08:33:08 +0000 Subject: [PATCH 077/128] Add Nuke 11.0 --- .../system_settings/applications.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 6a0ddb398e..a5283751e9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -344,13 +344,30 @@ }, "environment": {} }, + "11-0": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.0v4\\Nuke11.0.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, "__dynamic_keys_labels__": { "13-2": "13.2", "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2" + "11-2": "11.2", + "11-0": "11.0" } } }, From 7a1099b57e351e47d57e500c9184d2183527e61d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:08:49 +0100 Subject: [PATCH 078/128] fix access to bundles in dev mode --- openpype/settings/ayon_settings.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..5b179158f0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1436,7 +1436,10 @@ class _AyonSettingsCache: def _use_bundles(cls): if _AyonSettingsCache.use_bundles is None: major, minor, _, _, _ = ayon_api.get_server_version_tuple() - _AyonSettingsCache.use_bundles = major == 0 and minor >= 3 + use_bundles = True + if (major, minor) < (0, 3): + use_bundles = False + _AyonSettingsCache.use_bundles = use_bundles return _AyonSettingsCache.use_bundles @classmethod @@ -1467,7 +1470,7 @@ class _AyonSettingsCache: bundles = ayon_api.get_bundles() user = ayon_api.get_user() username = user["name"] - for bundle in bundles: + for bundle in bundles["bundles"]: if ( bundle.get("isDev") and bundle.get("activeUser") == username From 13ec4d9a537296e648a4cf33ab5e9da865e78145 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:31:58 +0100 Subject: [PATCH 079/128] fix formatting order --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index e8b85d0e93..15bde39f68 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -106,7 +106,7 @@ class _ModuleClass(object): if attr_name in self.__attributes__: self.log.warning( "Duplicated name \"{}\" in {}. Overriding.".format( - self.name, attr_name + attr_name, self.name ) ) self.__attributes__[attr_name] = value From 36f928151dc1e9a9996654ff8d698b7f1b51058a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:32:18 +0100 Subject: [PATCH 080/128] safe call of get plugins path --- openpype/modules/base.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 15bde39f68..f47baa0e4d 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -997,7 +997,17 @@ class ModulesManager: continue method = getattr(module, method_name) - paths = method(*args, **kwargs) + try: + paths = method(*args, **kwargs) + except Exception: + self.log.warning( + "Failed to get plugin paths from module {}.".format( + module.__class__.__name__ + ), + exc_info=True + ) + continue + if paths: # Convert to list if value is not list if not isinstance(paths, (list, tuple, set)): From f912c2c69c9743be16705781d4a388c355ecf68c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:32:35 +0100 Subject: [PATCH 081/128] change if conditions order --- openpype/modules/base.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index f47baa0e4d..1a3280a6e5 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -467,19 +467,19 @@ def _load_ayon_addons(openpype_modules, modules_key, log): )) continue - if len(imported_modules) == 1: - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - v3_addons_to_skip.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) + if len(imported_modules) > 1: + log.info("More then one module '{}' was imported.".format(name)) + continue - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) + mod = imported_modules[0] + addon_alias = getattr(mod, "V3_ALIAS", None) + if not addon_alias: + addon_alias = addon_name + v3_addons_to_skip.append(addon_alias) + new_import_str = "{}.{}".format(modules_key, addon_alias) - else: - log.info("More then one module was imported") + sys.modules[new_import_str] = mod + setattr(openpype_modules, addon_alias, mod) return v3_addons_to_skip From d55ac7aff7cd7d470ddac5aa9d8f9d2b43cf2cd2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:23 +0100 Subject: [PATCH 082/128] removed unused import --- openpype/hosts/tvpaint/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 58fbd09545..a84f196f09 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -7,7 +7,7 @@ import requests import pyblish.api -from openpype.client import get_project, get_asset_by_name +from openpype.client import get_asset_by_name from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings From 9d77421d9de2b2a7cee1d8c9e32290a632f6ac80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:36 +0100 Subject: [PATCH 083/128] use AYON label when in AYON mode --- openpype/hosts/tvpaint/api/communication_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index d67ef8f798..34302eef6e 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,6 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError +from openpype import AYON_SERVER_ENABLED from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path @@ -834,8 +835,9 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): + title = "AYON Tools" if AYON_SERVER_ENABLED else "OpenPype Tools" menu_definitions = { - "title": "OpenPype Tools", + "title": title, "menu_items": [ { "callback": "workfiles_tool", From 0cc90ceb081a5988192a993374b488ea0051618f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:46 +0100 Subject: [PATCH 084/128] removed unused 'previous_context' data --- openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 95a5cd77bd..56b51c812a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -69,7 +69,6 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "asset_name": context.data["asset"], "task_name": context.data["task"] } - context.data["previous_context"] = current_context self.log.debug("Current context is: {}".format(current_context)) # Collect context from workfile metadata From add4a1566d9dde015ac9b4422c0497f1c8710ab0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:40:41 +0100 Subject: [PATCH 085/128] Use 'AVALON_LABEL' for label --- openpype/hosts/tvpaint/api/communication_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 34302eef6e..2c4d8160a6 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -835,7 +835,10 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): - title = "AYON Tools" if AYON_SERVER_ENABLED else "OpenPype Tools" + label = os.getenv("AVALON_LABEL") + if not label: + label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" + title = "{} Tools".format(label) menu_definitions = { "title": title, "menu_items": [ From e9699d2cef653a00b185ed04d7874c458ae18f94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:43:27 +0100 Subject: [PATCH 086/128] fix grammar and use warning Co-authored-by: Roy Nieterau --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 1a3280a6e5..2d3f0d4bc1 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -468,7 +468,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue if len(imported_modules) > 1: - log.info("More then one module '{}' was imported.".format(name)) + log.warning("More than one module '{}' was imported.".format(name)) continue mod = imported_modules[0] From 3428ec08a5592e54586ea8f0126e4907a8f4eeee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 13:46:05 +0100 Subject: [PATCH 087/128] log which method was used to get plugins --- openpype/modules/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 2d3f0d4bc1..03ec3d271a 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -1001,9 +1001,10 @@ class ModulesManager: paths = method(*args, **kwargs) except Exception: self.log.warning( - "Failed to get plugin paths from module {}.".format( - module.__class__.__name__ - ), + ( + "Failed to get plugin paths from module" + " '{}' using '{}'." + ).format(module.__class__.__name__, method_name), exc_info=True ) continue From 2d940227b1144e67c0dc37384575aea067a37d25 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:54:29 +0100 Subject: [PATCH 088/128] skip openpype addon --- openpype/modules/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 03ec3d271a..aa3deff475 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -408,6 +408,10 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_name = addon_info["name"] addon_version = addon_info["version"] + # OpenPype addon does not have any addon object + if addon_name == "openpype": + continue + dev_addon_info = dev_addons_info.get(addon_name, {}) use_dev_path = dev_addon_info.get("enabled", False) From 27dd549c7dae21960f38e548598da4d884b61cd8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:54:40 +0100 Subject: [PATCH 089/128] ignore pycahce folders --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index aa3deff475..df7286e7b7 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -442,7 +442,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon # Ignore start and setup scripts - if name in ("setup.py", "start.py"): + if name in ("setup.py", "start.py", "__pycache__"): continue path = os.path.join(addon_dir, name) From 87c3682d61392b1828e6b4c09060a77400f14b00 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:55:06 +0100 Subject: [PATCH 090/128] imported modules must have Module class --- openpype/modules/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index df7286e7b7..eb6e7d6b73 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -458,7 +458,15 @@ def _load_ayon_addons(openpype_modules, modules_key, log): try: mod = __import__(basename, fromlist=("",)) - imported_modules.append(mod) + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + inspect.isclass(attr) + and issubclass(attr, OpenPypeModule) + ): + imported_modules.append(mod) + break + except BaseException: log.warning( "Failed to import \"{}\"".format(basename), From 541d333ab8f2ee0b92bab36d96b8c07e7a2457dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:56:33 +0100 Subject: [PATCH 091/128] more specific message when loaded multiple modules in addon dir --- openpype/modules/base.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index eb6e7d6b73..457e29905d 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -480,7 +480,14 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue if len(imported_modules) > 1: - log.warning("More than one module '{}' was imported.".format(name)) + log.warning(( + "Skipping addon '{}'." + " Multiple modules were found ({}) in dir {}." + ).format( + addon_name, + ", ".join([m.__name__ for m in imported_modules]), + addon_dir, + )) continue mod = imported_modules[0] From 6a619d023b4f4bac7d48883bd16081193950d950 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 3 Nov 2023 00:53:37 +0100 Subject: [PATCH 092/128] Remove on instance toggled callback which isn't relevant to the new publisher --- openpype/hosts/houdini/api/pipeline.py | 56 ------------------- .../publish/collect_instances_usd_layered.py | 4 -- 2 files changed, 60 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index f8db45c56b..11135e20b2 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -3,7 +3,6 @@ import os import sys import logging -import contextlib import hou # noqa @@ -66,10 +65,6 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_event_callback("open", on_open) register_event_callback("new", on_new) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - self._has_been_setup = True # add houdini vendor packages hou_pythonpath = os.path.join(HOUDINI_HOST_DIR, "vendor") @@ -406,54 +401,3 @@ def _set_context_settings(): lib.reset_framerange() lib.update_houdini_vars_context() - - -def on_pyblish_instance_toggled(instance, new_value, old_value): - """Toggle saver tool passthrough states on instance toggles.""" - @contextlib.contextmanager - def main_take(no_update=True): - """Enter root take during context""" - original_take = hou.takes.currentTake() - original_update_mode = hou.updateModeSetting() - root = hou.takes.rootTake() - has_changed = False - try: - if original_take != root: - has_changed = True - if no_update: - hou.setUpdateMode(hou.updateMode.Manual) - hou.takes.setCurrentTake(root) - yield - finally: - if has_changed: - if no_update: - hou.setUpdateMode(original_update_mode) - hou.takes.setCurrentTake(original_take) - - if not instance.data.get("_allowToggleBypass", True): - return - - nodes = instance[:] - if not nodes: - return - - # Assume instance node is first node - instance_node = nodes[0] - - if not hasattr(instance_node, "isBypassed"): - # Likely not a node that can actually be bypassed - log.debug("Can't bypass node: %s", instance_node.path()) - return - - if instance_node.isBypassed() != (not old_value): - print("%s old bypass state didn't match old instance state, " - "updating anyway.." % instance_node.path()) - - try: - # Go into the main take, because when in another take changing - # the bypass state of a note cannot be done due to it being locked - # by default. - with main_take(no_update=True): - instance_node.bypass(not new_value) - except hou.PermissionError as exc: - log.warning("%s - %s", instance_node.path(), exc) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py index 0600730d00..d154cdc7c0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py @@ -122,10 +122,6 @@ class CollectInstancesUsdLayered(pyblish.api.ContextPlugin): instance.data.update(save_data) instance.data["usdLayer"] = layer - # Don't allow the Pyblish `instanceToggled` we have installed - # to set this node to bypass. - instance.data["_allowToggleBypass"] = False - instances.append(instance) # Store the collected ROP node dependencies From 8fb7266ff8f0ea9c3b3f387ca120280093993294 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:18:09 +0200 Subject: [PATCH 093/128] Add CollectAssetHandles and modify associated files --- openpype/hosts/houdini/api/lib.py | 20 +-- .../plugins/publish/collect_arnold_rop.py | 2 +- .../plugins/publish/collect_asset_handles.py | 125 ++++++++++++++++++ .../houdini/plugins/publish/collect_frames.py | 10 +- .../plugins/publish/collect_karma_rop.py | 2 +- .../plugins/publish/collect_mantra_rop.py | 2 +- .../plugins/publish/collect_redshift_rop.py | 2 +- .../publish/collect_rop_frame_range.py | 79 +---------- .../plugins/publish/collect_vray_rop.py | 2 +- .../plugins/publish/validate_frame_range.py | 2 +- .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_publish.json | 4 +- .../houdini/server/settings/publish.py | 2 +- 13 files changed, 151 insertions(+), 103 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/collect_asset_handles.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ac375c56d6..c6722fb1bb 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -569,9 +569,9 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node, handle_start=0, handle_end=0, log=None): - """Get the frame data: start frame, end frame, steps, - start frame with start handle and end frame with end handle. +def get_frame_data(node, log=None): + """Get the frame data: `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. This function uses Houdini node's `trange`, `t1, `t2` and `t3` parameters as the source of truth for the full inclusive frame @@ -579,20 +579,17 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): range including the handles. The non-inclusive frame start and frame end without handles - are computed by subtracting the handles from the inclusive + can be computed by subtracting the handles from the inclusive frame range. Args: node (hou.Node): ROP node to retrieve frame range from, the frame range is assumed to be the frame range *including* the start and end handles. - handle_start (int): Start handles. - handle_end (int): End handles. - log (logging.Logger): Logger to log to. Returns: - dict: frame data for start, end, steps, - start with handle and end with handle + dict: frame data for `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. """ @@ -623,11 +620,6 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") - data["handleStart"] = handle_start - data["handleEnd"] = handle_end - data["frameStart"] = data["frameStartHandle"] + data["handleStart"] - data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 420a8324fe..d95f763826 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -22,7 +22,7 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): label = "Arnold ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py new file mode 100644 index 0000000000..5c11948608 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Collector plugin for frames data on ROP instances.""" +import hou # noqa +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectAssetHandles(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Apply asset handles. + + If instance does not have: + - frameStart + - frameEnd + - handleStart + - handleEnd + But it does have: + - frameStartHandle + - frameEndHandle + + Then we will retrieve the asset's handles to compute + the exclusive frame range and actual handle ranges. + """ + + hosts = ["houdini"] + + # This specific order value is used so that + # this plugin runs after CollectAnatomyInstanceData + order = pyblish.api.CollectorOrder + 0.499 + + label = "Collect Asset Handles" + use_asset_handles = True + + + def process(self, instance): + # Only process instances without already existing handles data + # but that do have frameStartHandle and frameEndHandle defined + # like the data collected from CollectRopFrameRange + if "frameStartHandle" not in instance.data: + return + if "frameEndHandle" not in instance.data: + return + + has_existing_data = { + "handleStart", + "handleEnd", + "frameStart", + "frameEnd" + }.issubset(instance.data) + if has_existing_data: + return + + attr_values = self.get_attr_values_from_data(instance.data) + if attr_values.get("use_handles", self.use_asset_handles): + asset_data = instance.data["assetEntity"]["data"] + handle_start = asset_data.get("handleStart", 0) + handle_end = asset_data.get("handleEnd", 0) + else: + handle_start = 0 + handle_end = 0 + + frame_start = instance.data["frameStartHandle"] + handle_start + frame_end = instance.data["frameEndHandle"] - handle_end + + instance.data.update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": frame_start, + "frameEnd": frame_end + }) + + # Log debug message about the collected frame range + if attr_values.get("use_handles", self.use_asset_handles): + self.log.debug( + "Full Frame range with Handles " + "[{frame_start_handle} - {frame_end_handle}]" + .format( + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + else: + self.log.debug( + "Use handles is deactivated for this instance, " + "start and end handles are set to 0." + ) + + # Log collected frame range to the user + message = "Frame range [{frame_start} - {frame_end}]".format( + frame_start=frame_start, + frame_end=frame_end + ) + if handle_start or handle_end: + message += " with handles [{handle_start}]-[{handle_end}]".format( + handle_start=handle_start, + handle_end=handle_end + ) + self.log.info(message) + + if instance.data.get("byFrameStep", 1.0) != 1.0: + self.log.info( + "Frame steps {}".format(instance.data["byFrameStep"])) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{label} [{frame_start} - {frame_end}]" + .format( + label=label, + frame_start=frame_start, + frame_end=frame_end + ) + ) + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("use_handles", + tooltip="Disable this if you want the publisher to" + " ignore start and end handles specified in the" + " asset data for this publish instance", + default=cls.use_asset_handles, + label="Use asset handles") + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 79cfcc6139..f6f538f5a5 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -13,7 +13,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # This specific order value is used so that # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "bgeo"] @@ -22,8 +22,8 @@ class CollectFrames(pyblish.api.InstancePlugin): ropnode = hou.node(instance.data["instance_node"]) - start_frame = instance.data.get("frameStart", None) - end_frame = instance.data.get("frameEnd", None) + start_frame = instance.data.get("frameStartHandle", None) + end_frame = instance.data.get("frameEndHandle", None) output_parm = lib.get_output_parameter(ropnode) if start_frame is not None: @@ -53,7 +53,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result if end_frame - start_frame > 0: - result = self.create_file_list( + result = self.create_file_list(self, match, int(start_frame), int(end_frame) ) @@ -62,7 +62,7 @@ class CollectFrames(pyblish.api.InstancePlugin): instance.data.update({"frames": result}) @staticmethod - def create_file_list(match, start_frame, end_frame): + def create_file_list(self,match, start_frame, end_frame): """Collect files based on frame range and `regex.match` Args: diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index a477529df9..dac350a6ef 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -26,7 +26,7 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): label = "Karma ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 9f0ae8d33c..a3e7927807 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -26,7 +26,7 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): label = "Mantra ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index 0bd7b41641..0acddab011 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -26,7 +26,7 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): label = "Redshift ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 186244fedd..1e6bc3b16e 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -2,22 +2,15 @@ """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api -from openpype.lib import BoolDef from openpype.hosts.houdini.api import lib -from openpype.pipeline import OpenPypePyblishPluginMixin -class CollectRopFrameRange(pyblish.api.InstancePlugin, - OpenPypePyblishPluginMixin): - +class CollectRopFrameRange(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" hosts = ["houdini"] - # This specific order value is used so that - # this plugin runs after CollectAnatomyInstanceData - order = pyblish.api.CollectorOrder + 0.499 + order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" - use_asset_handles = True def process(self, instance): @@ -30,78 +23,16 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return ropnode = hou.node(node_path) - - attr_values = self.get_attr_values_from_data(instance.data) - - if attr_values.get("use_handles", self.use_asset_handles): - asset_data = instance.data["assetEntity"]["data"] - handle_start = asset_data.get("handleStart", 0) - handle_end = asset_data.get("handleEnd", 0) - else: - handle_start = 0 - handle_end = 0 - frame_data = lib.get_frame_data( - ropnode, handle_start, handle_end, self.log + ropnode, self.log ) if not frame_data: return # Log debug message about the collected frame range - frame_start = frame_data["frameStart"] - frame_end = frame_data["frameEnd"] - - if attr_values.get("use_handles", self.use_asset_handles): - self.log.debug( - "Full Frame range with Handles " - "[{frame_start_handle} - {frame_end_handle}]" - .format( - frame_start_handle=frame_data["frameStartHandle"], - frame_end_handle=frame_data["frameEndHandle"] - ) - ) - else: - self.log.debug( - "Use handles is deactivated for this instance, " - "start and end handles are set to 0." - ) - - # Log collected frame range to the user - message = "Frame range [{frame_start} - {frame_end}]".format( - frame_start=frame_start, - frame_end=frame_end + self.log.debug( + "Collected frame_data: {}".format(frame_data) ) - if handle_start or handle_end: - message += " with handles [{handle_start}]-[{handle_end}]".format( - handle_start=handle_start, - handle_end=handle_end - ) - self.log.info(message) - - if frame_data.get("byFrameStep", 1.0) != 1.0: - self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) instance.data.update(frame_data) - - # Add frame range to label if the instance has a frame range. - label = instance.data.get("label", instance.data["name"]) - instance.data["label"] = ( - "{label} [{frame_start} - {frame_end}]" - .format( - label=label, - frame_start=frame_start, - frame_end=frame_end - ) - ) - - @classmethod - def get_attribute_defs(cls): - return [ - BoolDef("use_handles", - tooltip="Disable this if you want the publisher to" - " ignore start and end handles specified in the" - " asset data for this publish instance", - default=cls.use_asset_handles, - label="Use asset handles") - ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 519c12aede..64de2079cd 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -26,7 +26,7 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): label = "VRay ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["vray_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 6a66f3de9f..b49cfae901 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -89,7 +89,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): .format(instance)) return - created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa + created_instance.publish_attributes["CollectAssetHandles"]["use_handles"] = False # noqa create_context.save_changes() cls.log.debug("use asset handles is turned off for '{}'" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 87983620ec..0dd8443e44 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -137,7 +137,7 @@ } }, "publish": { - "CollectRopFrameRange": { + "CollectAssetHandles": { "use_asset_handles": true }, "ValidateContainers": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index 0de9f21c9f..324cfd8d58 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -11,8 +11,8 @@ { "type": "dict", "collapsible": true, - "key": "CollectRopFrameRange", - "label": "Collect Rop Frame Range", + "key": "CollectAssetHandles", + "label": "Collect Asset Handles", "children": [ { "type": "label", diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 6615e34ca5..342bf957c1 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -3,7 +3,7 @@ from ayon_server.settings import BaseSettingsModel # Publish Plugins -class CollectRopFrameRangeModel(BaseSettingsModel): +class CollectAssetHandlesModel(BaseSettingsModel): """Collect Frame Range Disable this if you want the publisher to ignore start and end handles specified in the From 38d71e7c73ef5716b2964c867fe81d1b26656aed Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:19:08 +0200 Subject: [PATCH 094/128] Bump Houdini addon version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 6cd38b7465..c49a95c357 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__ = "0.2.8" From 3d74095521a45337d764a70eaf794fa7f98dc226 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:21:10 +0200 Subject: [PATCH 095/128] Resolve Hound and Remove debug code --- .../hosts/houdini/plugins/publish/collect_asset_handles.py | 1 - openpype/hosts/houdini/plugins/publish/collect_frames.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py index 5c11948608..6474d64765 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -32,7 +32,6 @@ class CollectAssetHandles(pyblish.api.InstancePlugin, label = "Collect Asset Handles" use_asset_handles = True - def process(self, instance): # Only process instances without already existing handles data # but that do have frameStartHandle and frameEndHandle defined diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index f6f538f5a5..cdef642174 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -53,7 +53,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result if end_frame - start_frame > 0: - result = self.create_file_list(self, + result = self.create_file_list( match, int(start_frame), int(end_frame) ) @@ -62,7 +62,7 @@ class CollectFrames(pyblish.api.InstancePlugin): instance.data.update({"frames": result}) @staticmethod - def create_file_list(self,match, start_frame, end_frame): + def create_file_list(match, start_frame, end_frame): """Collect files based on frame range and `regex.match` Args: From 69bf065851a7b82db1779a72022abf941d5c56bd Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 15:41:37 +0200 Subject: [PATCH 096/128] BigRoy's Comment - Update label --- .../hosts/houdini/plugins/publish/collect_asset_handles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py index 6474d64765..67a281639d 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -104,11 +104,11 @@ class CollectAssetHandles(pyblish.api.InstancePlugin, # Add frame range to label if the instance has a frame range. label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( - "{label} [{frame_start} - {frame_end}]" + "{label} [{frame_start_handle} - {frame_end_handle}]" .format( label=label, - frame_start=frame_start, - frame_end=frame_end + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] ) ) From 6e9c3b227815e7e8cbd158a8110549b2f095443e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 3 Nov 2023 15:06:59 +0100 Subject: [PATCH 097/128] deadline: settings are not blocking extension input --- server_addon/deadline/server/settings/publish_plugins.py | 2 +- server_addon/deadline/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d48695a9c..54b7ff57c1 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -267,7 +267,7 @@ class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): title="Reviewable products filter", ) - @validator("aov_filter", "skip_integration_repre_list") + @validator("aov_filter") def validate_unique_names(cls, value): ensure_unique_names(value) return value diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 9154dbab05ec7f5b921874b5d523720df7c9409a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 15:45:15 +0000 Subject: [PATCH 098/128] Fix loading of blend layout --- openpype/hosts/blender/plugins/load/load_blend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 25d6568889..3d6b634916 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -32,7 +32,7 @@ class BlendLoader(plugin.AssetLoader): empties = [obj for obj in objects if obj.type == 'EMPTY'] for empty in empties: - if empty.get(AVALON_PROPERTY): + if empty.get(AVALON_PROPERTY) and empty.parent is None: return empty return None @@ -90,6 +90,7 @@ class BlendLoader(plugin.AssetLoader): members.append(data) container = self._get_asset_container(data_to.objects) + print(container) assert container, "No asset group found" container.name = group_name @@ -100,8 +101,11 @@ class BlendLoader(plugin.AssetLoader): # Link all the container children to the collection for obj in container.children_recursive: + print(obj) bpy.context.scene.collection.objects.link(obj) + print("") + # Remove the library from the blend file library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) From dbd1fcb98912616d03c456718baf9b3f2e65a03c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 17:03:54 +0100 Subject: [PATCH 099/128] make sure all QThread objects are always removed from python memory --- .../ayon_utils/widgets/folders_widget.py | 3 +- .../ayon_utils/widgets/projects_widget.py | 13 +++-- .../tools/ayon_utils/widgets/tasks_widget.py | 49 ++++++++++--------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 322553c51c..b72a992858 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -104,8 +104,8 @@ class FoldersModel(QtGui.QStandardItemModel): if not project_name: self._last_project_name = project_name - self._current_refresh_thread = None self._fill_items({}) + self._current_refresh_thread = None return self._is_refreshing = True @@ -152,6 +152,7 @@ class FoldersModel(QtGui.QStandardItemModel): return self._fill_items(thread.get_result()) + self._current_refresh_thread = None def _fill_item_data(self, item, folder_item): """ diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index be18cfe3ed..05347faca4 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -35,12 +35,11 @@ class ProjectsModel(QtGui.QStandardItemModel): self._selected_project = None - self._is_refreshing = False self._refresh_thread = None @property def is_refreshing(self): - return self._is_refreshing + return self._refresh_thread is not None def refresh(self): self._refresh() @@ -169,15 +168,16 @@ class ProjectsModel(QtGui.QStandardItemModel): return self._select_item def _refresh(self): - if self._is_refreshing: + if self._refresh_thread is not None: return - self._is_refreshing = True + refresh_thread = RefreshThread( "projects", self._query_project_items ) refresh_thread.refresh_finished.connect(self._refresh_finished) - refresh_thread.start() + self._refresh_thread = refresh_thread + refresh_thread.start() def _query_project_items(self): return self._controller.get_project_items() @@ -185,11 +185,10 @@ class ProjectsModel(QtGui.QStandardItemModel): def _refresh_finished(self): # TODO check if failed result = self._refresh_thread.get_result() - self._refresh_thread = None self._fill_items(result) - self._is_refreshing = False + self._refresh_thread = None self.refreshed.emit() def _fill_items(self, project_items): diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index d01b3a7917..a6375c6ae6 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -185,28 +185,7 @@ class TasksModel(QtGui.QStandardItemModel): thread.refresh_finished.connect(self._on_refresh_thread) thread.start() - def _on_refresh_thread(self, thread_id): - """Callback when refresh thread is finished. - - Technically can be running multiple refresh threads at the same time, - to avoid using values from wrong thread, we check if thread id is - current refresh thread id. - - Tasks are stored by name, so if a folder has same task name as - previously selected folder it keeps the selection. - - Args: - thread_id (str): Thread id. - """ - - # Make sure to remove thread from '_refresh_threads' dict - thread = self._refresh_threads.pop(thread_id) - if ( - self._current_refresh_thread is None - or thread_id != self._current_refresh_thread.id - ): - return - + def _fill_data_from_thread(self, thread): task_items = thread.get_result() # Task items are refreshed if task_items is None: @@ -247,7 +226,33 @@ class TasksModel(QtGui.QStandardItemModel): if new_items: root_item.appendRows(new_items) + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None self._is_refreshing = False self.refreshed.emit() From f18d0d9f8f36fcdc7193aaea5588db391fc5acec Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 18:09:48 +0200 Subject: [PATCH 100/128] include full frame range in representation dictionary --- openpype/hosts/houdini/plugins/publish/extract_ass.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_bgeo.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_composite.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 6 +++--- openpype/hosts/houdini/plugins/publish/extract_opengl.py | 4 ++-- .../hosts/houdini/plugins/publish/extract_redshift_proxy.py | 6 +++--- openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_ass.py b/openpype/hosts/houdini/plugins/publish/extract_ass.py index 0d246625ba..be60217055 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_ass.py +++ b/openpype/hosts/houdini/plugins/publish/extract_ass.py @@ -56,7 +56,7 @@ class ExtractAss(publish.Extractor): 'ext': ext, "files": files, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index c9625ec880..d13141b426 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -47,7 +47,7 @@ class ExtractBGEO(publish.Extractor): "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"] + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"] } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_composite.py b/openpype/hosts/houdini/plugins/publish/extract_composite.py index 7a1ab36b93..11cf83a46d 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_composite.py +++ b/openpype/hosts/houdini/plugins/publish/extract_composite.py @@ -41,8 +41,8 @@ class ExtractComposite(publish.Extractor): "ext": ext, "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } from pprint import pformat diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 7993b3352f..1be61ecce1 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -40,9 +40,9 @@ class ExtractFBX(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] # set value type for 'representations' key to list if "representations" not in instance.data: diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py index 6c36dec5f5..38808089ac 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_opengl.py +++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py @@ -39,8 +39,8 @@ class ExtractOpenGL(publish.Extractor): "ext": instance.data["imageFormat"], "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": instance.data.get("review_camera") diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 1d99ac665c..18cbd5712e 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -44,8 +44,8 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 4bca758f08..89af8e1756 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -40,7 +40,7 @@ class ExtractVDBCache(publish.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) From 2a19d5cc548e55bd526f24a70494717fbe5f23c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:57:15 +0100 Subject: [PATCH 101/128] fix default type of projects model cache --- openpype/tools/ayon_utils/models/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 4ad53fbbfa..383f676c64 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -87,7 +87,7 @@ def _get_project_items_from_entitiy(projects): class ProjectsModel(object): def __init__(self, controller): - self._projects_cache = CacheItem(default_factory=dict) + self._projects_cache = CacheItem(default_factory=list) self._project_items_by_name = {} self._projects_by_name = {} From 3bffe3b31b3b367e4a24f0446d2b217e3623ecbe Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:58:29 +0100 Subject: [PATCH 102/128] renamed 'ProjectsModel' to 'ProjectsQtModel' --- openpype/tools/ayon_launcher/ui/projects_widget.py | 4 ++-- openpype/tools/ayon_utils/widgets/__init__.py | 4 ++-- openpype/tools/ayon_utils/widgets/projects_widget.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index 7dbaec5147..31c36719a6 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets, QtCore from openpype.tools.flickcharm import FlickCharm from openpype.tools.utils import PlaceholderLineEdit, RefreshButton from openpype.tools.ayon_utils.widgets import ( - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER @@ -95,7 +95,7 @@ class ProjectsWidget(QtWidgets.QWidget): projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) flick.activateOn(projects_view) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 432a249a73..1ef7dfe482 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -1,7 +1,7 @@ from .projects_widget import ( # ProjectsWidget, ProjectsCombobox, - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) @@ -25,7 +25,7 @@ from .utils import ( __all__ = ( # "ProjectsWidget", "ProjectsCombobox", - "ProjectsModel", + "ProjectsQtModel", "ProjectSortFilterProxy", "FoldersWidget", diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 05347faca4..9f0f839281 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -10,11 +10,11 @@ PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 -class ProjectsModel(QtGui.QStandardItemModel): +class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ProjectsModel, self).__init__() + super(ProjectsQtModel, self).__init__() self._controller = controller self._project_items = {} @@ -402,7 +402,7 @@ class ProjectsCombobox(QtWidgets.QWidget): projects_combobox = QtWidgets.QComboBox(self) combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) projects_combobox.setModel(projects_proxy_model) From 264e3cac79b863c5eb45841860da7789a13e714f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:59:02 +0100 Subject: [PATCH 103/128] renamed other qt models to contain 'Qt' --- openpype/tools/ayon_loader/ui/folders_widget.py | 4 ++-- openpype/tools/ayon_utils/widgets/__init__.py | 8 ++++---- openpype/tools/ayon_utils/widgets/folders_widget.py | 6 +++--- openpype/tools/ayon_utils/widgets/tasks_widget.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 53351f76d9..eaaf7ca617 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -8,7 +8,7 @@ from openpype.tools.utils import ( from openpype.style import get_objected_colors from openpype.tools.ayon_utils.widgets import ( - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE @@ -182,7 +182,7 @@ class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): painter.restore() -class LoaderFoldersModel(FoldersModel): +class LoaderFoldersModel(FoldersQtModel): def __init__(self, *args, **kwargs): super(LoaderFoldersModel, self).__init__(*args, **kwargs) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 1ef7dfe482..f58de17c4a 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -7,13 +7,13 @@ from .projects_widget import ( from .folders_widget import ( FoldersWidget, - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, - TasksModel, + TasksQtModel, TASKS_MODEL_SENDER_NAME, ) from .utils import ( @@ -29,11 +29,11 @@ __all__ = ( "ProjectSortFilterProxy", "FoldersWidget", - "FoldersModel", + "FoldersQtModel", "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", - "TasksModel", + "TasksQtModel", "TASKS_MODEL_SENDER_NAME", "get_qt_icon", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index b72a992858..44323a192c 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -16,7 +16,7 @@ FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class FoldersModel(QtGui.QStandardItemModel): +class FoldersQtModel(QtGui.QStandardItemModel): """Folders model which cares about refresh of folders. Args: @@ -26,7 +26,7 @@ class FoldersModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(FoldersModel, self).__init__() + super(FoldersQtModel, self).__init__() self._controller = controller self._items_by_id = {} @@ -282,7 +282,7 @@ class FoldersWidget(QtWidgets.QWidget): folders_view = TreeView(self) folders_view.setHeaderHidden(True) - folders_model = FoldersModel(controller) + folders_model = FoldersQtModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index a6375c6ae6..f27711acdd 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -12,7 +12,7 @@ ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class TasksModel(QtGui.QStandardItemModel): +class TasksQtModel(QtGui.QStandardItemModel): """Tasks model which cares about refresh of tasks by folder id. Args: @@ -22,7 +22,7 @@ class TasksModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(TasksModel, self).__init__() + super(TasksQtModel, self).__init__() self._controller = controller @@ -285,7 +285,7 @@ class TasksModel(QtGui.QStandardItemModel): if section == 0: return "Tasks" - return super(TasksModel, self).headerData( + return super(TasksQtModel, self).headerData( section, orientation, role ) @@ -310,7 +310,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) - tasks_model = TasksModel(controller) + tasks_model = TasksQtModel(controller) tasks_proxy_model = QtCore.QSortFilterProxyModel() tasks_proxy_model.setSourceModel(tasks_model) tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) From 86e4bed1514a1506200ac3ca9c51956c95414b2b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:59:52 +0100 Subject: [PATCH 104/128] validate that item on which is clicked is enabled --- openpype/tools/ayon_launcher/ui/projects_widget.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index 31c36719a6..38c7f62bd5 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -133,9 +133,14 @@ class ProjectsWidget(QtWidgets.QWidget): return self._projects_model.has_content() def _on_view_clicked(self, index): - if index.isValid(): - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) + if not index.isValid(): + return + model = index.model() + flags = model.flags(index) + if not flags & QtCore.Qt.ItemIsEnabled: + return + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) def _on_project_filter_change(self, text): self._projects_proxy_model.setFilterFixedString(text) From 05748bbb9291424ed82495a63bc581b2437fe772 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:00:51 +0100 Subject: [PATCH 105/128] projects model pass sender value --- openpype/tools/ayon_utils/widgets/projects_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 9f0f839281..804b7a05ac 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -180,7 +180,9 @@ class ProjectsQtModel(QtGui.QStandardItemModel): refresh_thread.start() def _query_project_items(self): - return self._controller.get_project_items() + return self._controller.get_project_items( + sender=PROJECTS_MODEL_SENDER + ) def _refresh_finished(self): # TODO check if failed From 42c32f81969399bc900fdfe2900d0da0e8c3edea Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:01:26 +0100 Subject: [PATCH 106/128] projects model returns 'None' if is in middle of refreshing --- openpype/tools/ayon_utils/models/projects.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 383f676c64..36d53edc24 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -103,8 +103,18 @@ class ProjectsModel(object): self._refresh_projects_cache() def get_project_items(self, sender): + """ + + Args: + sender (str): Name of sender who asked for items. + + Returns: + Union[list[ProjectItem], None]: List of project items, or None + if model is refreshing. + """ + if not self._projects_cache.is_valid: - self._refresh_projects_cache(sender) + return self._refresh_projects_cache(sender) return self._projects_cache.get_data() def get_project_entity(self, project_name): @@ -136,11 +146,12 @@ class ProjectsModel(object): def _refresh_projects_cache(self, sender=None): if self._is_refreshing: - return + return None with self._project_refresh_event_manager(sender): project_items = self._query_projects() self._projects_cache.update_data(project_items) + return self._projects_cache.get_data() def _query_projects(self): projects = ayon_api.get_projects(fields=["name", "active", "library"]) From 5a0b2f69153f71469b60acf78d588c3583493764 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:01:40 +0100 Subject: [PATCH 107/128] projects model handle cases when model is refreshing --- openpype/tools/ayon_utils/widgets/projects_widget.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 804b7a05ac..2beee29cb9 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -1,3 +1,5 @@ +import uuid + from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER @@ -187,11 +189,14 @@ class ProjectsQtModel(QtGui.QStandardItemModel): def _refresh_finished(self): # TODO check if failed result = self._refresh_thread.get_result() - - self._fill_items(result) + if result is not None: + self._fill_items(result) self._refresh_thread = None - self.refreshed.emit() + if result is None: + self._refresh() + else: + self.refreshed.emit() def _fill_items(self, project_items): new_project_names = { From 779cea668862a9354816f5fb9c62bf0d71496951 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 20:04:16 +0200 Subject: [PATCH 108/128] update dictionary keys to be frames with handles --- .../hosts/houdini/plugins/publish/collect_review_data.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py index 3efb75e66c..9671945b9a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_review_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py @@ -6,6 +6,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): """Collect Review Data.""" label = "Collect Review Data" + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 hosts = ["houdini"] families = ["review"] @@ -41,8 +43,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): return if focal_length_parm.isTimeDependent(): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + 1 + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + 1 focal_length = [ focal_length_parm.evalAsFloatAtFrame(t) for t in range(int(start), int(end)) From 15a52469354238165236ed890d10679e932f4439 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 20:07:15 +0200 Subject: [PATCH 109/128] Resolve Hound --- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 2 +- .../hosts/houdini/plugins/publish/extract_redshift_proxy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 1be61ecce1..7dc193c6a9 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -40,7 +40,7 @@ class ExtractFBX(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 18cbd5712e..ef5991924f 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -44,7 +44,7 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] From d87506f8af15123cdc0d07176dba26bce262a434 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:12:17 +0100 Subject: [PATCH 110/128] removed unused import --- openpype/tools/ayon_utils/widgets/projects_widget.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 2beee29cb9..f98bfcdf8a 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -1,5 +1,3 @@ -import uuid - from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER From bd94b8dcc972fe8f954b9b10de46350d1515864c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 4 Nov 2023 03:25:15 +0000 Subject: [PATCH 111/128] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 4865fcfb31..c6ebd65e9c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.2" +__version__ = "3.17.5-nightly.3" From 2ab07ec7ede740c13d98e8abeca9c02f73db4182 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Nov 2023 03:25:51 +0000 Subject: [PATCH 112/128] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 249da3da0e..7a1fe9d83e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.5-nightly.3 - 3.17.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.5 - 3.15.1-nightly.4 - 3.15.1-nightly.3 - - 3.15.1-nightly.2 validations: required: true - type: dropdown From 8f21d653e079662eef75a435ea97b26d101d7567 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 6 Nov 2023 21:32:43 +0800 Subject: [PATCH 113/128] use product_types instead of families --- server_addon/core/server/settings/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index d7c7b367b7..0dd9d396ae 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -489,7 +489,7 @@ DEFAULT_TOOLS_VALUES = { "template_name": "publish_online" }, { - "families": [ + "product_types": [ "tycache" ], "hosts": [ From b7bec4b4e4fea704b0706013e76ac03de325ebee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 6 Nov 2023 18:28:51 +0100 Subject: [PATCH 114/128] use correct label for follow workfile version --- server_addon/core/server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index 69a759465e..93d8db964d 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class ValidateBaseModel(BaseSettingsModel): class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = Field( - True, title="Collect Anatomy Instance Data" + True, title="Follow workfile version" ) From efa9b8fe4c1b22af0aa561f6ea9132687b703f5e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Nov 2023 18:39:58 +0100 Subject: [PATCH 115/128] Remove unused `instanceToggled` callbacks (#5862) --- openpype/hosts/aftereffects/api/pipeline.py | 10 ---------- openpype/hosts/nuke/api/pipeline.py | 22 --------------------- openpype/hosts/photoshop/api/pipeline.py | 10 ---------- openpype/hosts/tvpaint/api/pipeline.py | 4 ---- 4 files changed, 46 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 8fc7a70dd8..e059f7c272 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -74,11 +74,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", application_launch) @@ -186,11 +181,6 @@ def application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active AfterEffects document. diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index f6ba33f00f..ba4d66ab63 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -129,9 +129,6 @@ class NukeHost( register_event_callback("workio.open_file", check_inventory_versions) register_event_callback("taskChanged", change_context_label) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled) - _install_menu() # add script menu @@ -402,25 +399,6 @@ def add_shortcuts_from_presets(): log.error(e) -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle node passthrough states on instance toggles.""" - - log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( - instance, old_value, new_value)) - - # Whether instances should be passthrough based on new value - - with viewer_update_and_undo_stop(): - n = instance[0] - try: - n["publish"].value() - except ValueError: - n = add_publish_knob(n) - log.info(" `Publish` knob was added to write node..") - - n["publish"].setValue(new_value) - - def containerise(node, name, namespace, diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 56ae2a4c25..4e0dbcad06 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -48,11 +48,6 @@ class PhotoshopHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", on_application_launch) @@ -177,11 +172,6 @@ def on_application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active Photoshop document diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index a84f196f09..c125da1533 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -84,10 +84,6 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(load_dir) register_creator_plugin_path(create_dir) - registered_callbacks = ( - pyblish.api.registered_callbacks().get("instanceToggled") or [] - ) - register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) From ce413045130184c6da299d1f3a4a441a550a146d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 13:29:15 +0100 Subject: [PATCH 116/128] ignore if passed icon definition is None --- openpype/tools/ayon_utils/widgets/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py index 8bc3b1ea9b..2817b5efc0 100644 --- a/openpype/tools/ayon_utils/widgets/utils.py +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -54,6 +54,8 @@ class _IconsCache: @classmethod def get_icon(cls, icon_def): + if not icon_def: + return None icon_type = icon_def["type"] cache_key = cls._get_cache_key(icon_def) cache = cls._cache.get(cache_key) From bf96b15b90e04b0721af853adeb161707ebd5b8c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 13:50:44 +0100 Subject: [PATCH 117/128] center publisher window on first show --- openpype/tools/publisher/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 312cf1dd5c..2416763c27 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -15,6 +15,7 @@ from openpype.tools.utils import ( MessageOverlayObject, PixmapLabel, ) +from openpype.tools.utils.lib import center_window from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget @@ -529,6 +530,7 @@ class PublisherWindow(QtWidgets.QDialog): def _on_first_show(self): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + center_window(self) self._reset_on_show = self._reset_on_first_show def _on_show_timer(self): From 414df2370346aabbb555212f8123bbcb35817ea2 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 7 Nov 2023 13:07:26 +0000 Subject: [PATCH 118/128] Introduce app_group flag (#5869) --- openpype/cli.py | 7 +++++-- openpype/pype_commands.py | 5 ++++- tests/conftest.py | 10 ++++++++++ tests/lib/testing_classes.py | 9 ++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 7422f32f13..f0fe550a1f 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -282,6 +282,9 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) +@click.option("--app_group", + help="Provide specific app group for test, empty for default", + default=None) @click.option("-t", "--timeout", help="Provide specific timeout value for test case", @@ -294,11 +297,11 @@ def run(script): help="MongoDB for testing.", default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only, mongo_url): + timeout, setup_only, mongo_url, app_group): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url) + mongo_url, app_group) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 071ecfffd2..b5828d3dfe 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -214,7 +214,7 @@ class PypeCommands: def run_tests(self, folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url): + mongo_url, app_group): """ Runs tests from 'folder' @@ -260,6 +260,9 @@ class PypeCommands: if persist: args.extend(["--persist", persist]) + if app_group: + args.extend(["--app_group", app_group]) + if app_variant: args.extend(["--app_variant", app_variant]) diff --git a/tests/conftest.py b/tests/conftest.py index 6e82c9917d..a862030fff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,11 @@ def pytest_addoption(parser): help="True - keep test_db, test_openpype, outputted test files" ) + parser.addoption( + "--app_group", action="store", default=None, + help="Keep empty to use default application or explicit" + ) + parser.addoption( "--app_variant", action="store", default=None, help="Keep empty to locate latest installed variant or explicit" @@ -45,6 +50,11 @@ def persist(request): return request.config.getoption("--persist") +@pytest.fixture(scope="module") +def app_group(request): + return request.config.getoption("--app_group") + + @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 277b332e19..e8e338e434 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -248,19 +248,22 @@ class PublishTest(ModuleUnitTest): SETUP_ONLY = False @pytest.fixture(scope="module") - def app_name(self, app_variant): + def app_name(self, app_variant, app_group): """Returns calculated value for ApplicationManager. Eg.(nuke/12-2)""" from openpype.lib import ApplicationManager app_variant = app_variant or self.APP_VARIANT + app_group = app_group or self.APP_GROUP application_manager = ApplicationManager() if not app_variant: variant = ( application_manager.find_latest_available_variant_for_group( - self.APP_GROUP)) + app_group + ) + ) app_variant = variant.name - yield "{}/{}".format(self.APP_GROUP, app_variant) + yield "{}/{}".format(app_group, app_variant) @pytest.fixture(scope="module") def app_args(self, download_test_data): From 82c3442f6192d2f4e26268a89483e55f4cebfbaa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 14:46:05 +0100 Subject: [PATCH 119/128] convert 'ValidateAttributes' settings only if are available --- openpype/settings/ayon_settings.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index a31c8a04e0..b56249bbc2 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -641,13 +641,14 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute # --- Publish (START) --- ayon_publish = ayon_max["publish"] - try: - attributes = json.loads( - ayon_publish["ValidateAttributes"]["attributes"] - ) - except ValueError: - attributes = {} - ayon_publish["ValidateAttributes"]["attributes"] = attributes + if "ValidateAttributes" in ayon_publish: + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes output["max"] = ayon_max From e3c28bd55775ebb0d8b1d412dc7155ba0aa8d152 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 7 Nov 2023 13:46:43 +0000 Subject: [PATCH 120/128] [Automated] Release --- CHANGELOG.md | 457 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 459 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7432b33e24..b3daf581ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,463 @@ # Changelog +## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.4...3.17.5) + +### **🆕 New features** + + +
+Fusion: Add USD loader #4896 + +Add an OpenPype managed USD loader (`uLoader`) for Fusion. + + +___ + +
+ + +
+Fusion: Resolution validator #5325 + +Added a resolution validator.The code is from my old PR (https://github.com/ynput/OpenPype/pull/4921) that I closed because the PR also contained a frame range validator that no longer is needed. + + +___ + +
+ + +
+Context Selection tool: Refactor Context tool (for AYON) #5766 + +Context selection tool has AYON variant. + + +___ + +
+ + +
+AYON: Use AYON username for user in template data #5842 + +Use ayon username for template data in AYON mode. + + +___ + +
+ + +
+Testing: app_group flag #5869 + +`app_group` command flag. This is for changing which flavour of the host to launch. In the case of Maya, you can launch Maya and MayaPy, but it can be used for the Nuke family as well.Split from #5644 + + +___ + +
+ +### **🚀 Enhancements** + + +
+Enhancement: Fusion fix saver creation + minor Blender/Fusion logging tweaks #5558 + +- Blender change logs to `debug` level in preparation for new publisher artist facing reports (note that it currently still uses the old publisher) +- Fusion: Create Saver fix redeclaration of default_variants +- Fusion: Fix saver being created in incorrect state without saving directly after create +- Fusion: Allow reset frame range on render family +- Fusion: Tweak logging level for artist-facing report + + +___ + +
+ + +
+Resolve: load clip to timeline at set time #5665 + +It is possible to load clip to correct place on timeline. + + +___ + +
+ + +
+Nuke: Optional Deadline workfile dependency. #5732 + +Adds option to add the workfile as dependency for the Deadline job.Think it used to have something like this, but it disappeared. Usecase is for remote workflow where the Nuke script needs to be synced before the job can start. + + +___ + +
+ + +
+Enhancement/houdini rearrange ayon houdini settings files #5748 + +Rearranging Houdini Settings to be more readable, easier to edit, update settings (include all families/product types)This PR is mainly for Ayon Settings to have more organized files. For Openpype, I'll make sure that each Houdini setting in Ayon has an equivalent in Openpype. +- [x] update Ayon settings, fix typos and remove deprecated settings. +- [x] Sync with Openpype +- [x] Test in Openpype +- [x] Test in Ayon + + +___ + +
+ + +
+Chore: updating create ayon addon script #5822 + +Adding developers environment options. + + +___ + +
+ + +
+Max: Implement Validator for Properties/Attributes Value Check #5824 + +Add optional validator which can check if the property attributes are valid in Max + + +___ + +
+ + +
+Nuke: Remove unused 'get_render_path' function #5826 + +Remove unused function `get_render_path` from nuke integration. + + +___ + +
+ + +
+Chore: Limit current context template data function #5845 + +Current implementation of `get_current_context_template_data` does return the same values as base template data function `get_template_data`. + + +___ + +
+ + +
+Max: Make sure Collect Render not ignoring instance asset #5847 + +- Make sure Collect Render is not always using asset from context. +- Make sure Scene version being collected +- Clean up unnecessary uses of code in the collector. + + +___ + +
+ + +
+Ftrack: Events are not processed if project is not available in OpenPype #5853 + +Events that happened on project which is not in OpenPype is not processed. + + +___ + +
+ + +
+Nuke: Add Nuke 11.0 as default setting #5855 + +Found I needed Nuke 11.0 in the default settings to help with unit testing. + + +___ + +
+ + +
+TVPaint: Code cleanup #5857 + +Removed unused import. Use `AYON` label in ayon mode. Removed unused data in publish context `"previous_context"`. + + +___ + +
+ + +
+AYON settings: Use correct label for follow workfile version #5874 + +Follow workfile version label was marked as Collect Anatomy Instance Data label. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Nuke: Fix workfile template builder so representations get loaded next to each other #5061 + +Refactor when the cleanup of the placeholder happens for the cases where multiple representations are loaded by a single placeholder.The existing code didn't take into account the case where a template placeholder can load multiple representations so it was trying to do the cleanup of the placeholder node and the re-arrangement of the imported nodes too early. I assume this was designed only for the cases where a single representation can load multiple nodes. + + +___ + +
+ + +
+Nuke: Dont update node name on update #5704 + +When updating `Image` containers the code is trying to set the name of the node. This results in a warning message from Nuke shown below;Suggesting to not change the node name when updating. + + +___ + +
+ + +
+UIDefLabel can be unique #5827 + +`UILabelDef` have implemented comparison and uniqueness. + + +___ + +
+ + +
+AYON: Skip kitsu module when creating ayon addons #5828 + +Create AYON packages is skipping kitsu module in creation of modules/addons and kitsu module is not loaded from modules on start. The addon already has it's repository https://github.com/ynput/ayon-kitsu. + + +___ + +
+ + +
+Bugfix: Collect Rendered Files only collecting first instance #5832 + +Collect all instances from the metadata file - don't return on first instance iteration. + + +___ + +
+ + +
+Houdini: set frame range for the created composite ROP #5833 + +Quick bug fix for created composite ROP, set its frame range to the frame range of the playbar. + + +___ + +
+ + +
+Fix registering launcher actions from OpenPypeModules #5843 + +Fix typo `actions_dir` -> `path` to fix register launcher actions fromm OpenPypeModule + + +___ + +
+ + +
+Bugfix in houdini shelves manager and beautify settings #5844 + +This PR fixes the problem in this PR https://github.com/ynput/OpenPype/issues/5457 by using the right function to load a pre-made houdini `.shelf` fileAlso, it beautifies houdini shelves settings to provide better guidance for users which helps with other issue https://github.com/ynput/OpenPype/issues/5458 , Rather adding default shelf and set names, I'll educate users how to use the tool correctly.Users now are able to select between the two options.| OpenPype | Ayon || -- | -- || | | + + +___ + +
+ + +
+Blender: Fix missing Grease Pencils in review #5848 + +Fix Grease Pencil missing in review when isolating objects. + + +___ + +
+ + +
+Blender: Fix Render Settings in Ayon #5849 + +Fix Render Settings in Ayon for Blender. + + +___ + +
+ + +
+Bugfix: houdini tab menu working as expected #5850 + +This PR:Tab menu name changes to Ayon when using ayon get_network_categories is checked in all creator plugins. | Product | Network Category | | -- | -- | | Alembic camera | rop, obj | | Arnold Ass | rop | | Arnold ROP | rop | | Bgeo | rop, sop | | composite sequence | cop2, rop | | hda | obj | | Karma ROP | rop | | Mantra ROP | rop | | ABC | rop, sop | | RS proxy | rop, sop| | RS ROP | rop | | Review | rop | | Static mesh | rop, obj, sop | | USD | lop, rop | | USD Render | rop | | VDB | rop, obj, sop | | V Ray | rop | + + +___ + +
+ + +
+Bigfix: Houdini skip frame_range_validator if node has no 'trange' parameter #5851 + +I faced a bug when publishing HDA instance as it has no `trange` parameter. As this PR title says : skip frame_range_validator if node has no 'trange' parameter + + +___ + +
+ + +
+Bugfix: houdini image sequence loading and missing frames #5852 + +I made this PR in to fix issues mentioned here https://github.com/ynput/OpenPype/pull/5833#issuecomment-1789207727in short: +- image load doesn't work +- publisher only publish one frame + + +___ + +
+ + +
+Nuke: loaders' containers updating as nodes #5854 + +Nuke loaded containers are updating correctly even they have been duplicating of originally loaded nodes. This had previously been removed duplicated nodes. + + +___ + +
+ + +
+deadline: settings are not blocking extension input #5864 + +Settings are not blocking user input. + + +___ + +
+ + +
+Blender: Fix loading of blend layouts #5866 + +Fix a problem with loading blend layouts. + + +___ + +
+ + +
+AYON: Launcher refresh issues #5867 + +Fixed refresh of projects issue in launcher tool. And renamed Qt models to contain `Qt` in their name (it was really hard to find out where were used). It is not possible to click on disabled item in launcher's projects view. + + +___ + +
+ + +
+Fix the Wrong key words for tycache workfile template settings in AYON #5870 + +Fix the wrong key words for the tycache workfile template settings in AYON(i.e. Instead of families, product_types should be used) + + +___ + +
+ + +
+AYON tools: Handle empty icon definition #5876 + +Ignore if passed icon definition is `None`. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Houdini: Remove on instance toggled callback #5860 + +Remove on instance toggled callback which isn't relevant to the new publisher + + +___ + +
+ + +
+Chore: Remove unused `instanceToggled` callbacks #5862 + +The `instanceToggled` callbacks should be irrelevant for new publisher. + + +___ + +
+ + + + ## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) diff --git a/openpype/version.py b/openpype/version.py index c6ebd65e9c..9832c77291 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.3" +__version__ = "3.17.5" diff --git a/pyproject.toml b/pyproject.toml index 633dafece1..c6f4880cdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.4" # OpenPype +version = "3.17.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 246a4af3ef2185b2ad924bd89c8b4f2bd4404678 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Nov 2023 13:47:43 +0000 Subject: [PATCH 121/128] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7a1fe9d83e..bdfc2ad46f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.5 - 3.17.5-nightly.3 - 3.17.5-nightly.2 - 3.17.5-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.6 - 3.15.1-nightly.5 - 3.15.1-nightly.4 - - 3.15.1-nightly.3 validations: required: true - type: dropdown From f7d76617c0ea5635f9ae8ad0e6a18454da24c2be Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 7 Nov 2023 15:51:54 +0000 Subject: [PATCH 122/128] Testing: Validate Maya Logs (#5775) * Working version * Improve launched app communication * Move imports to methods. * Update tests/integration/hosts/maya/test_publish_in_maya.py Co-authored-by: Roy Nieterau * Collect errors from process * fix startup scripts arguments * Update openpype/lib/applications.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Fix application polling * Docstring * Revert stdout and stderr * Revert subprocess.PIPE * Added missed imports If we are moving these because of testing, lets move all of them --------- Co-authored-by: Roy Nieterau Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: kalisp --- openpype/hosts/maya/api/lib_rendersettings.py | 6 ++-- .../collect_deadline_server_from_instance.py | 5 ++-- .../publish/submit_blender_deadline.py | 5 ++-- .../publish/submit_houdini_remote_publish.py | 3 +- .../publish/submit_houdini_render_deadline.py | 5 ++-- .../plugins/publish/submit_max_deadline.py | 14 +++++---- .../plugins/publish/submit_maya_deadline.py | 8 ++--- .../submit_maya_remote_publish_deadline.py | 5 ++-- .../plugins/publish/submit_nuke_deadline.py | 5 ++-- .../publish/collect_otio_frame_ranges.py | 18 +++++++----- .../plugins/publish/collect_otio_review.py | 7 +++-- .../publish/collect_otio_subset_resources.py | 17 ++++++----- .../publish/extract_otio_audio_tracks.py | 8 +++-- openpype/plugins/publish/extract_otio_file.py | 5 +++- .../plugins/publish/extract_otio_review.py | 24 ++++++++++----- .../publish/extract_otio_trimming_video.py | 3 +- tests/integration/hosts/maya/lib.py | 7 ++++- .../hosts/maya/test_publish_in_maya.py | 29 +++++++++++++++++++ tests/lib/testing_classes.py | 3 +- 19 files changed, 121 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index 42cf29d0a7..20264c2cdf 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- """Class for handling Render Settings.""" -from maya import cmds # noqa -import maya.mel as mel import six import sys @@ -63,6 +61,10 @@ class RenderSettings(object): def set_default_renderer_settings(self, renderer=None): """Set basic settings based on renderer.""" + # Not all hosts can import this module. + from maya import cmds + import maya.mel as mel + if not renderer: renderer = cmds.getAttr( 'defaultRenderGlobals.currentRenderer').lower() diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 9b4f89c129..1d3dad769f 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -5,8 +5,6 @@ This is resolving index of server lists stored in `deadlineServers` instance attribute or using default server if that attribute doesn't exists. """ -from maya import cmds - import pyblish.api from openpype.pipeline.publish import KnownPublishError @@ -44,7 +42,8 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): str: Selected Deadline Webservice URL. """ - + # Not all hosts can import this module. + from maya import cmds deadline_settings = ( render_instance.context.data ["system_settings"] diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 4a7497b075..094f2b1821 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -6,8 +6,6 @@ import getpass import attr from datetime import datetime -import bpy - from openpype.lib import is_running_from_build from openpype.pipeline import legacy_io from openpype.pipeline.farm.tools import iter_expected_files @@ -142,6 +140,9 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import bpy + plugin_info = BlenderPluginInfo( SceneFile=self.scene_path, Version=bpy.app.version_string, diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 39c0c3afe4..0bee42c4cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -3,7 +3,6 @@ import json from datetime import datetime import requests -import hou import pyblish.api @@ -31,6 +30,8 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): targets = ["deadline"] def process(self, context): + # Not all hosts can import this module. + import hou # Ensure no errors so far assert all( diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 6f885c578a..abc650204b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -1,9 +1,8 @@ -import hou - import os import attr import getpass from datetime import datetime + import pyblish.api from openpype.pipeline import legacy_io @@ -119,6 +118,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import hou instance = self._instance context = instance.context diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 073da3019a..23d4183132 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -1,8 +1,8 @@ import os import getpass import copy - import attr + from openpype.lib import ( TextDef, BoolDef, @@ -15,11 +15,6 @@ from openpype.pipeline import ( from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) -from openpype.hosts.max.api.lib import ( - get_current_renderer, - get_multipass_setting -) -from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import is_running_from_build @@ -191,6 +186,13 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): + # Not all hosts can import these modules. + from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting + ) + from openpype.hosts.max.api.lib_rendersettings import RenderSettings + instance = self._instance job_info = copy.deepcopy(self.job_info) plugin_info = copy.deepcopy(self.plugin_info) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 7775191b12..7d532923ff 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -28,8 +28,6 @@ from collections import OrderedDict import attr -from maya import cmds - from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin @@ -246,6 +244,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info def get_plugin_info(self): + # Not all hosts can import this module. + from maya import cmds instance = self._instance context = instance.context @@ -288,7 +288,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return plugin_payload def process_submission(self): - + from maya import cmds instance = self._instance filepath = self.scene_path # publish if `use_publish` else workfile @@ -675,7 +675,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, str """ - + from maya import cmds # "vrayscene//_/" vray_settings = cmds.ls(type="VRaySettingsNode") node = vray_settings[0] diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 0d23f44333..41a2a64ab5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -2,8 +2,6 @@ import os import attr from datetime import datetime -from maya import cmds - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests @@ -127,7 +125,8 @@ class MayaSubmitRemotePublishDeadline( job_info.EnvironmentKeyValue[key] = value def get_plugin_info(self): - + # Not all hosts can import this module. + from maya import cmds scene = self._instance.context.data["currentFile"] plugin_info = MayaPluginInfo() diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 0e57c54959..fb3ab2710d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -7,8 +7,6 @@ from datetime import datetime import requests import pyblish.api -import nuke - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( @@ -498,6 +496,9 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, Returning: list: captured groups list """ + # Not all hosts can import this module. + import nuke + captured_groups = [] for lg_name, list_node_class in self.limit_groups.items(): for node_class in list_node_class: diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index 9a68b6e43d..4b130b0e03 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -5,15 +5,9 @@ Requires: masterLayer -> instance data attribute otioClipRange -> instance data attribute """ -# import os -import opentimelineio as otio -import pyblish.api from pprint import pformat -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles -) + +import pyblish.api class CollectOtioFrameRanges(pyblish.api.InstancePlugin): @@ -27,6 +21,14 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles + ) + # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index f0157282a1..0e4d596213 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -11,10 +11,10 @@ Provides: instance -> families (adding ["review", "ftrack"]) """ -import opentimelineio as otio -import pyblish.api from pprint import pformat +import pyblish.api + class CollectOtioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" @@ -25,6 +25,9 @@ class CollectOtioReview(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + # get basic variables otio_review_clips = [] otio_timeline = instance.context.data["otioTimeline"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index f659791d95..739f5bb726 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -6,18 +6,15 @@ Provides: instance -> otioReviewClips """ import os + import clique -import opentimelineio as otio import pyblish.api -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - range_from_frames, - make_sequence_collection -) + from openpype.pipeline.publish import ( get_publish_template_name ) + class CollectOtioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" @@ -26,8 +23,14 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): families = ["clip"] hosts = ["resolve", "hiero", "flame"] - def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + range_from_frames, + make_sequence_collection + ) if "audio" in instance.data["family"]: return diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 4f17731452..4b73321f02 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -1,11 +1,12 @@ import os +import tempfile + import pyblish + from openpype.lib import ( get_ffmpeg_tool_args, run_subprocess ) -import tempfile -import opentimelineio as otio class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -155,6 +156,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): Returns: list: list of audio clip dictionaries """ + # Not all hosts can import this module. + import opentimelineio as otio + output = [] # go trough all audio tracks for otio_track in otio_timeline.tracks: diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index 1a6a82117d..7f1cac33d7 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -1,6 +1,6 @@ import os + import pyblish.api -import opentimelineio as otio from openpype.pipeline import publish @@ -16,6 +16,9 @@ class ExtractOTIOFile(publish.Extractor): hosts = ["resolve", "hiero", "traypublisher"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + if not instance.context.data.get("otioTimeline"): return # create representation data diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 699207df8a..ad4c807091 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -15,8 +15,8 @@ Provides: """ import os + import clique -import opentimelineio as otio from pyblish import api from openpype.lib import ( @@ -24,13 +24,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import ( - otio_range_to_frame_range, - trim_media_range, - range_from_frames, - frames_to_seconds, - make_sequence_collection -) class ExtractOTIOReview(publish.Extractor): @@ -62,6 +55,13 @@ class ExtractOTIOReview(publish.Extractor): output_ext = ".jpg" def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + otio_range_to_frame_range, + make_sequence_collection + ) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -281,6 +281,12 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import these modules. + from openpype.pipeline.editorial import ( + trim_media_range, + range_from_frames + ) + avl_start = int(avl_range.start_time.value) src_start = int(avl_start + start) avl_durtation = int(avl_range.duration.value) @@ -338,6 +344,8 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path and frame start to destination output_path, out_frame_start = self._get_ffmpeg_output() diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 67ff6c538c..2020fcde93 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -15,7 +15,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import frames_to_seconds class ExtractOTIOTrimmingVideo(publish.Extractor): @@ -75,6 +74,8 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): otio_range (opentime.TimeRange): range to trim to """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path to destination output_path = self._get_ffmpeg_output(input_file_path) diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index f27d516605..04ddb765a4 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -33,7 +33,7 @@ class MayaHostFixtures(HostFixtures): yield dest_path @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session): + def startup_scripts(self, monkeypatch_session, download_test_data): """Points Maya to userSetup file from input data""" startup_path = os.path.join( os.path.dirname(__file__), "input", "startup" @@ -44,6 +44,11 @@ class MayaHostFixtures(HostFixtures): "{}{}{}".format(startup_path, os.pathsep, original_pythonpath) ) + monkeypatch_session.setenv( + "MAYA_CMD_FILE_OUTPUT", + os.path.join(download_test_data, "output.log") + ) + @pytest.fixture(scope="module") def skip_compare_folders(self): yield [] diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py index b7ee228aae..be8c74e0b8 100644 --- a/tests/integration/hosts/maya/test_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_publish_in_maya.py @@ -1,3 +1,6 @@ +import re +import os + from tests.lib.assert_classes import DBAssert from tests.integration.hosts.maya.lib import MayaLocalPublishTestClass @@ -35,6 +38,32 @@ class TestPublishInMaya(MayaLocalPublishTestClass): TIMEOUT = 120 # publish timeout + def test_publish( + self, + dbcon, + publish_finished, + download_test_data + ): + """Testing Pyblish and Python logs within Maya.""" + + # All maya output via MAYA_CMD_FILE_OUTPUT env var during test run + logging_path = os.path.join(download_test_data, "output.log") + with open(logging_path, "r") as f: + logging_output = f.read() + + print(("-" * 50) + "LOGGING" + ("-" * 50)) + print(logging_output) + + # Check for pyblish errors. + error_regex = r"pyblish \(ERROR\)((.|\n)*?)((pyblish \())" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + + # Check for python errors. + error_regex = r"// Error((.|\n)*)" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index e8e338e434..3b0611e2a0 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -10,6 +10,7 @@ import glob import platform import requests import re +import time from tests.lib.db_handler import DBHandler from tests.lib.file_handler import RemoteFileHandler @@ -334,7 +335,7 @@ class PublishTest(ModuleUnitTest): print("Creating only setup for test, not launching app") yield False return - import time + time_start = time.time() timeout = timeout or self.TIMEOUT timeout = float(timeout) From 63af150dd8f08b280bb6cb1ebe918dd4025e50a5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:22:02 +0100 Subject: [PATCH 123/128] confirm of instance context changes reset origin of input fields --- openpype/tools/publisher/widgets/widgets.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1bbe73381f..77ebc3f0bb 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -579,6 +579,10 @@ class AssetsField(BaseClickableFrame): """Change to asset names set with last `set_selected_items` call.""" self.set_selected_items(self._origin_value) + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + class TasksComboboxProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): @@ -785,6 +789,18 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) + def confirm_value(self): + new_task_name = self._selected_items[0] + origin_value = copy.deepcopy(self._origin_value) + new_origin_value = [ + (asset_name, new_task_name) + for (asset_name, task_name) in origin_value + ] + + self._origin_value = new_origin_value + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + def set_selected_items(self, asset_task_combinations=None): """Set items for selected instances. @@ -919,6 +935,10 @@ class VariantInputWidget(PlaceholderLineEdit): """Change text of multiselection.""" self._multiselection_text = text + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + def _set_is_valid(self, valid): if valid == self._is_valid: return @@ -1210,6 +1230,15 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self._set_btns_enabled(False) self._set_btns_visible(invalid_tasks) + if variant_value is not None: + self.variant_input.confirm_value() + + if asset_name is not None: + self.asset_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value() + self.instance_context_changed.emit() def _on_cancel(self): From e4ed21623a57618fa6889750c5cf97ec9cc1872d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:35:48 +0100 Subject: [PATCH 124/128] fix task combinations --- openpype/tools/publisher/widgets/widgets.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 77ebc3f0bb..9b31697749 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -789,15 +789,12 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) - def confirm_value(self): + def confirm_value(self, asset_names): new_task_name = self._selected_items[0] - origin_value = copy.deepcopy(self._origin_value) - new_origin_value = [ + self._origin_value = [ (asset_name, new_task_name) - for (asset_name, task_name) in origin_value + for asset_name in asset_names ] - - self._origin_value = new_origin_value self._origin_selection = copy.deepcopy(self._selected_items) self._has_value_changed = False @@ -1180,6 +1177,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): subset_names = set() invalid_tasks = False + asset_names = [] for instance in self._current_instances: new_variant_value = instance.get("variant") new_asset_name = instance.get("asset") @@ -1193,6 +1191,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if task_name is not None: new_task_name = task_name + asset_names.append(new_asset_name) try: new_subset_name = self._controller.get_subset_name( instance.creator_identifier, @@ -1237,7 +1236,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self.asset_value_widget.confirm_value() if task_name is not None: - self.task_value_widget.confirm_value() + self.task_value_widget.confirm_value(asset_names) self.instance_context_changed.emit() From b8ed125569bdfd7c343d08483bfd70431d38f11d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:44:33 +0100 Subject: [PATCH 125/128] set spacing between buttons --- openpype/tools/publisher/widgets/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 9b31697749..6dbeaad821 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1127,6 +1127,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) + btns_layout.setSpacing(5) btns_layout.addWidget(submit_btn) btns_layout.addWidget(cancel_btn) From 8c8c083395b77e920254e8dd5d83ad06eb0c636d Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 8 Nov 2023 03:24:51 +0000 Subject: [PATCH 126/128] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 9832c77291..8500b78966 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5" +__version__ = "3.17.6-nightly.1" From 3cd8fc6a0a8162a73d647f17a66294b0c79b2724 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Nov 2023 03:25:29 +0000 Subject: [PATCH 127/128] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bdfc2ad46f..5d4db81a77 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.6-nightly.1 - 3.17.5 - 3.17.5-nightly.3 - 3.17.5-nightly.2 @@ -134,7 +135,6 @@ body: - 3.15.1 - 3.15.1-nightly.6 - 3.15.1-nightly.5 - - 3.15.1-nightly.4 validations: required: true - type: dropdown From 74d0f944afd6ce2e719204c0505981f5fd77d952 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 8 Nov 2023 15:44:32 +0000 Subject: [PATCH 128/128] Do not pack image if it is already packed --- .../blender/plugins/publish/extract_blend.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index c8eeef7fd7..645314e50e 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -28,16 +28,22 @@ class ExtractBlend(publish.Extractor): for obj in instance: data_blocks.add(obj) # Pack used images in the blend files. - if obj.type == 'MESH': - for material_slot in obj.material_slots: - mat = material_slot.material - if mat and mat.use_nodes: - tree = mat.node_tree - if tree.type == 'SHADER': - for node in tree.nodes: - if node.bl_idname == 'ShaderNodeTexImage': - if node.image: - node.image.pack() + if obj.type != 'MESH': + continue + for material_slot in obj.material_slots: + mat = material_slot.material + if not(mat and mat.use_nodes): + continue + tree = mat.node_tree + if tree.type != 'SHADER': + continue + for node in tree.nodes: + if node.bl_idname != 'ShaderNodeTexImage': + continue + # Check if image is not packed already + # and pack it if not. + if node.image and node.image.packed_file is None: + node.image.pack() bpy.data.libraries.write(filepath, data_blocks)