74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241 | class CollectUSDValueClips(plugin.HoudiniInstancePlugin):
"""Collect USD value clips that are to be written out for a USD publish.
It detects sequence clips inside the current stage
to be written on executing a USD ROP.
"""
label = "Collect USD Value Clips"
# Run after core plugin `CollectResourcesPath`
order = pyblish.api.CollectorOrder + 0.496
families = ["usd"]
def process(self, instance):
# For each layer in the output layer stack process any USD Value Clip
# nodes that are listed as 'editor nodes' in that graph.
for layer in instance.data.get("layers", []):
self._get_layer_value_clips(layer, instance)
def _get_layer_value_clips(self, layer, instance):
info_prim = layer.GetPrimAtPath("/HoudiniLayerInfo")
if not info_prim:
return
editor_nodes = info_prim.customData.get("HoudiniEditorNodes")
if not editor_nodes:
return
# Get frame range of the ROP node
start: int = int(instance.data["frameStartHandle"])
end: int = int(instance.data["frameEndHandle"])
asset_remap = instance.data.setdefault("assetRemap", {})
for node_id in editor_nodes:
# Consider only geoclipsequence nodes
node = hou.nodeBySessionId(node_id)
if node.type().name() != "geoclipsequence":
continue
self.log.debug(
f"Collecting outputs for Geometry Clip Sequence: {node.path()}"
)
# Collect all their output files
files = self._get_geoclipsequence_output_files(node, start, end)
# Check if the layer is an explicit save layer, because if it is
# then likely it is collected as its own instance by the
# CollectUsdLayers plug-in and we want to attach the files to that
# layer instance instead.
target_instance = instance
if info_prim.customData.get("HoudiniSaveControl") == "Explicit":
override_instance = self._find_instance_by_explict_save_layer(
instance,
layer
)
if override_instance:
target_instance = override_instance
# Set up transfers
transfers = target_instance.data.setdefault("transfers", [])
resources_dir = target_instance.data["resourcesDir"]
resources_dir_name = os.path.basename(resources_dir)
for src in files:
# Make relative transfers of these files and remap
# them to relative paths from the published USD layer
src_name = os.path.basename(src)
transfers.append(
(src, os.path.join(resources_dir, src_name))
)
asset_remap[src] = f"./{resources_dir_name}/{src_name}"
self.log.debug(
"Registering transfer & remap: "
f"{src} -> {asset_remap[src]}"
)
def _get_geoclipsequence_output_files(
self, clip_node: hou.Node, start: int, end: int
) -> list[str]:
"""Return the output files for the given Geometry Clip Sequence node
that would be written out when executing the USD ROP for the given
frame range.
A Geometry Clip Sequence only writes out files for the frames that
appear in the ROP render range. If it has a start and end frame, then
it won't write out frames beyond those frame ranges. If it loops, then
it loops solely beyond the end frame, not before the start frame.
As such, we find the intersection of the frame ranges to determine the
files to be written out.
Args:
clip_node (hou.Node): The Geometry Clip Sequence node.
start (int): The ROP render start frame.
end (int): The ROP render end frame.
Returns:
list[str]: List of filepaths.
"""
# TODO: We may want to process this node in the Context Options of the
# USD ROP to be correct in the case of e.g. multishot workflows
# Collect the manifest and topology file
files: list[str] = [
clip_node.evalParm('manifestfile'),
clip_node.evalParm('topologyfile')
]
saveclipfilepath: str = \
clip_node.parm('saveclipfilepath').evalAtFrame(start)
frame_collection, _ = clique.assemble(
[saveclipfilepath],
patterns=[clique.PATTERNS["frames"]],
minimum_items=1
)
# Skip if no frame pattern detected.
if not frame_collection:
self.log.warning(
f"Unable detect frame sequence in filepath: {saveclipfilepath}"
)
# Assume it's some form of static clip file in this scenario
files.append(saveclipfilepath)
return files
# Collect the clip frames that fall within the render range
# because those will be the clip frames to be written out.
frames = get_clip_frames_in_frame_range(
clip_start=int(clip_node.evalParm('startframe')),
clip_end=int(clip_node.evalParm('endframe')),
has_end_set=bool(clip_node.evalParm('setendframe')),
loop=bool(clip_node.evalParm('loopframes')),
range_start=start,
range_end=end
)
# It's always expected to be one collection.
frame_collection = frame_collection[0]
frame_collection.indexes.clear()
frame_collection.indexes.update(frames)
files.extend(list(frame_collection))
return files
def _find_instance_by_explict_save_layer(
self,
instance: pyblish.api.Instance,
layer
) -> Optional[pyblish.api.Instance]:
"""Find the target instance (in context) for the given layer if it's
an explicit save layer.
If the layer is an explicit save layer, then try to find if there's a
publish instance for it and return it instead. Otherwise, return the
input instance.
"""
for other_instance in instance.context:
# Skip self
if instance is other_instance:
continue
if other_instance.data.get("usd_layer") is layer:
self.log.debug(
"Setting explicit save layer target instance: "
f"{other_instance}"
)
return other_instance
return None
|