From b918516f6483dfd72782e70f009fabc8f1587c57 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio Date: Wed, 26 Aug 2026 12:33:15 -0500 Subject: [PATCH 1/4] SG-44786 Reference previous pipeline step's published scene when building When building a new Maya scene for a downstream step, automatically reference the upstream step's published Maya workfile into the scene so it opens ready to work from. Referencing runs in the build prep callback (after the scene is created/loaded, before it is saved into the draft) and is a no-op unless the host is Maya and the upstream step actually has a published workfile. - step_validation: share the hierarchy walk via _find_workfile_asset() and add find_upstream_workfile() to resolve the upstream step's published workfile asset, returning None for every unresolved case so referencing is skipped. - reference: add reference_published_workfile(), mirroring reference_revision() without its flow_draft_id guard, since a freshly built scene has no draft context yet. - flowam_actions: wire referencing into the build prep callback, Maya-gated like the SG-44787 publish check; a reference failure warns but never aborts. Co-authored-by: Cursor --- .../tk_multi_loader/flowam/flowam_actions.py | 85 ++++++++++++- python/tk_multi_loader/flowam/reference.py | 64 ++++++++++ .../tk_multi_loader/flowam/step_validation.py | 115 +++++++++++++++--- 3 files changed, 246 insertions(+), 18 deletions(-) diff --git a/python/tk_multi_loader/flowam/flowam_actions.py b/python/tk_multi_loader/flowam/flowam_actions.py index c5e082f..fef423b 100644 --- a/python/tk_multi_loader/flowam/flowam_actions.py +++ b/python/tk_multi_loader/flowam/flowam_actions.py @@ -32,8 +32,12 @@ download_revision, open_draft, ) -from .reference import copy_reference_link, reference_revision -from .step_validation import find_unpublished_upstream_step +from .reference import ( + copy_reference_link, + reference_published_workfile, + reference_revision, +) +from .step_validation import find_unpublished_upstream_step, find_upstream_workfile class FlowAMActions: @@ -176,7 +180,11 @@ def _on_build_scene_dialog_accepted( am_project_id=flow_am_id, create_mode=dialog.build, source_path=template_path, - prep_scene_callback=functools.partial(self._prep_scene, sg_publish_data), + ) + # Bound after construction so the callback can reference create_inputs + # (which needs the fully built object to resolve the upstream scene). + create_inputs.prep_scene_callback = functools.partial( + self._prepare_build_scene, create_inputs, sg_publish_data ) if not self._confirm_upstream_step_published(create_inputs): @@ -262,6 +270,77 @@ def _prep_scene(self, sg_publish_data: dict) -> None: # TDs can override this method to add custom scene prep logic pass + def _prepare_build_scene( + self, create_inputs: CreateInputs, sg_publish_data: dict + ) -> None: + """ + Prep callback run while a new scene is being built. + + This fires after the host has created/loaded the scene and before it is + saved into the draft, which is exactly when the upstream reference must + exist so it gets baked into the built scene. Standard referencing runs + first, then the TD-overridable prep hook. + + :param create_inputs: Inputs describing the scene being built. + :param sg_publish_data: FPTR data dictionary for the task being built from. + """ + self._reference_upstream_step(create_inputs) + self._prep_scene(sg_publish_data) + + def _reference_upstream_step(self, create_inputs: CreateInputs) -> None: + """ + Reference the previous pipeline step's published Maya scene into the + scene being built. + + This is the referencing counterpart of + `_confirm_upstream_step_published`. It is a no-op unless the current host + is Maya (only the Maya scene publish is referenced) and the upstream step + actually has a published workfile. When nothing is published - for + instance when the artist chose to build an empty scene from that warning + - there is simply nothing to reference. + + A referencing failure is surfaced as a warning but never aborts the + build: the artist still gets their new scene, just without the reference. + + :param create_inputs: Inputs describing the scene being built. + """ + host = getattr(sgtk.platform.current_engine(), "flow_host", None) + workfile_type = getattr(host, "WORKFILE_TYPE", "") + if workfile_type != MAYA_WORKFILE_TYPE: + return + + workfile = find_upstream_workfile( + am_project_id=create_inputs.am_project_id, + sg_entity_type=create_inputs.sg_entity_type, + sg_entity_name=create_inputs.sg_entity_name, + sg_pipeline_step=create_inputs.sg_pipeline_step, + workfile_type=workfile_type, + step_dependencies=self._app.get_setting("pipeline_step_dependencies", {}), + ) + if workfile is None: + return + + try: + file_path = reference_published_workfile(workfile.revision_id) + except exceptions.FlowError as exc: + message = ( + f"Could not reference the previous step's published scene for " + f'"{create_inputs.sg_entity_name}". The new scene was built ' + f"without it. ({exc})" + ) + self._app.log_error(message) + QtGui.QMessageBox.warning( + self._get_dialog_parent(), + "Reference failed", + message, + ) + return + + self._app.log_info( + f"Referenced the previous step's published scene into the new " + f'"{create_inputs.sg_pipeline_step}" scene: {file_path}' + ) + def _discard_draft(self, sg_publish_data: dict) -> None: """ Discard the local draft for the given PublishedFile. diff --git a/python/tk_multi_loader/flowam/reference.py b/python/tk_multi_loader/flowam/reference.py index bc76d67..41a909c 100644 --- a/python/tk_multi_loader/flowam/reference.py +++ b/python/tk_multi_loader/flowam/reference.py @@ -89,6 +89,70 @@ def reference_revision(revision_id: str) -> str: return depdata.file_path +def reference_published_workfile(revision_id: str) -> str: + """Reference a published workfile's source into the current scene. + + Intended for the "Build New Scene" flow, where a downstream step's fresh + scene should open with the upstream step's published output already + referenced in. It intentionally mirrors :func:`reference_revision` but omits + its ``flow_draft_id`` guard: at build time the scene is brand new and has no + asset/draft context yet, which is exactly the state that guard rejects. + + Args: + revision_id: The id of the asset revision to be referenced. + This can also be a version id. + + Returns: + File path of referenced file. + + Raises: + CreateReferenceError + """ + engine = sgtk.platform.current_engine() + + if not hasattr(engine.flow_host, "create_reference"): + msg = "Referencing is not supported in current execution." + raise CreateReferenceError(input_id=revision_id, details=msg) + + try: + if objects.FlowVersion.is_version_id(revision_id): + input_type = "version" + revision = objects.FlowVersion(revision_id).revision + else: + input_type = "revision" + revision = objects.FlowRevision.get_revision(revision_id) + except exceptions.FlowError as exc: + msg = f"Could not retrieve {input_type} object." + raise CreateReferenceError(input_id=revision_id, details=msg) from exc + + # Fetch source component of revision + revision.fetch(component_purpose=globals.SOURCE_PURPOSE, fetch_dependencies=True) + + # Get path to source path of revision in local storage + file_path = revision.get_storage_component_path( + component_purpose=globals.SOURCE_PURPOSE + ) + if file_path is None: + msg = "Revision does not have a source component to be referenced." + raise CreateReferenceError(input_id=revision_id, details=msg) + file_seq_comp = revision.find_component( + type_id=schema.get_schema_id(globals.FILE_SEQ_TYPE) + ) + if not file_seq_comp and not os.path.exists(file_path): + msg = f"Source file does not exist in storage: {file_path}. " + msg += "Fetching the revision was not successful!" + raise CreateReferenceError(input_id=revision_id, details=msg) + elif file_seq_comp: + # Return a file path with embedded frame padding + file_path = utils.cleanpath( + revision.get_storage_dir(), file_seq_comp.properties["fileFormat"] + ) + + # Create reference + depdata = engine.flow_host.create_reference(file_path, namespace=revision.name) + return depdata.file_path + + def copy_reference_link(revision_id: str) -> str: """Copy the reference link (file path) to the source component the of given revision to application clipboard. diff --git a/python/tk_multi_loader/flowam/step_validation.py b/python/tk_multi_loader/flowam/step_validation.py index 2b032a4..954effd 100644 --- a/python/tk_multi_loader/flowam/step_validation.py +++ b/python/tk_multi_loader/flowam/step_validation.py @@ -124,21 +124,13 @@ def has_published_workfile( return True try: - node = objects.FlowProject(am_project_id) - # Walk down to the "root asset" grouping the workfiles of this step: - # Assets///. See get_or_create_workfile_parent() - # in tk-core's tank/flowam/create.py for the hierarchy this mirrors. - for name in ( - root_folder_name, - sg_entity_name, - pipeline_step, - sg_entity_name, - ): - node = node.find_child(name) - if node is None: - return False - - return bool(node.find_children(type_id=workfile_type_id)) + workfile = _find_workfile_asset( + am_project_id=am_project_id, + root_folder_name=root_folder_name, + sg_entity_name=sg_entity_name, + pipeline_step=pipeline_step, + workfile_type_id=workfile_type_id, + ) except exceptions.FlowError as exc: logger.warning( f'Could not verify whether pipeline step "{pipeline_step}" has a ' @@ -147,6 +139,99 @@ def has_published_workfile( ) return True + return workfile is not None + + +def find_upstream_workfile( + am_project_id: str, + sg_entity_type: str, + sg_entity_name: str, + sg_pipeline_step: str, + workfile_type: str, + step_dependencies: Dict[str, str], +) -> Optional[objects.FlowAsset]: + """Return the upstream step's published workfile asset for the entity. + + This is the referencing counterpart of + :func:`find_unpublished_upstream_step`. Where that helper answers "should we + warn?", this one answers "what should we reference?". Every unresolved case - + no configured upstream, an unsupported entity type, an unresolved workfile + schema id, nothing published, or a Flow AM query error - yields ``None`` so + the caller simply skips referencing rather than surfacing an error while + building a scene. + + :param am_project_id: Id of the Flow AM project holding the asset. + :param sg_entity_type: FPTR entity type of the asset, e.g. ``"Asset"``. + :param sg_entity_name: FPTR entity name of the asset. + :param sg_pipeline_step: Step the new scene is being built for. + :param workfile_type: Schema type name of the workfile to reference. + :param step_dependencies: Mapping of step name to upstream step name. + :returns: The upstream workfile ``FlowAsset``, or ``None``. + """ + upstream_step = get_upstream_step(sg_pipeline_step, step_dependencies) + if not upstream_step: + return None + + root_folder_name = _get_root_folder_name(sg_entity_type) + if not root_folder_name: + return None + + workfile_type_id = schema.get_schema_id(workfile_type) + if not workfile_type_id: + logger.warning( + f'Could not resolve the schema id for workfile type "{workfile_type}". ' + f'Skipping referencing of pipeline step "{upstream_step}".' + ) + return None + + try: + return _find_workfile_asset( + am_project_id=am_project_id, + root_folder_name=root_folder_name, + sg_entity_name=sg_entity_name, + pipeline_step=upstream_step, + workfile_type_id=workfile_type_id, + ) + except exceptions.FlowError as exc: + logger.warning( + f'Could not resolve a published workfile for pipeline step ' + f'"{upstream_step}" of "{sg_entity_name}". Skipping referencing. ({exc})' + ) + return None + + +def _find_workfile_asset( + am_project_id: str, + root_folder_name: str, + sg_entity_name: str, + pipeline_step: str, + workfile_type_id: str, +) -> Optional[objects.FlowAsset]: + """Return the workfile asset published under *pipeline_step* for the entity. + + Walks down to the "root asset" that groups the workfiles of a step - + ``///`` - and returns its first + workfile-typed child. See ``get_or_create_workfile_parent()`` in tk-core's + ``tank/flowam/create.py`` for the hierarchy this mirrors. + + :param am_project_id: Id of the Flow AM project holding the asset. + :param root_folder_name: Name of the project's top-level folder. + :param sg_entity_name: FPTR entity name of the asset. + :param pipeline_step: Step to look under. + :param workfile_type_id: Resolved schema id of the workfile type. + :returns: The workfile ``FlowAsset``, or ``None`` when the hierarchy is + incomplete or the step has no published workfile. + :raises exceptions.FlowError: If a Flow AM query fails. + """ + node = objects.FlowProject(am_project_id) + for name in (root_folder_name, sg_entity_name, pipeline_step, sg_entity_name): + node = node.find_child(name) + if node is None: + return None + + workfiles = node.find_children(type_id=workfile_type_id) + return workfiles[0] if workfiles else None + def _get_root_folder_name(sg_entity_type: str) -> Optional[str]: """Return the name of the top-level folder holding assets of *sg_entity_type*. From 3cfe3072c00fb22c3b000f72d3c429f172308aac Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio Date: Wed, 26 Aug 2026 12:33:24 -0500 Subject: [PATCH 2/4] SG-44786 Add tests for upstream workfile resolution Cover find_upstream_workfile(): returns the upstream step's published workfile asset, and returns None with no configured upstream, an unsupported entity type, an unresolved workfile schema id, nothing published, or a Flow AM error. Co-authored-by: Cursor --- tests/test_step_validation.py | 70 ++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/test_step_validation.py b/tests/test_step_validation.py index ad154b2..5fa659e 100644 --- a/tests/test_step_validation.py +++ b/tests/test_step_validation.py @@ -47,9 +47,10 @@ class StubWorkfile: """Stand-in for a workfile asset carrying a single schema type.""" - def __init__(self, name, type_id): + def __init__(self, name, type_id, revision_id="rev-1"): self.name = name self.type_id = type_id + self.revision_id = revision_id class StubNode: @@ -79,7 +80,13 @@ def build_project(steps, root_folder="Assets", entity_name=ENTITY_NAME): step_nodes = [] for step_name, is_published in steps.items(): workfiles = ( - [StubWorkfile(f"{entity_name} - MAYA", MAYA_TYPE_ID)] + [ + StubWorkfile( + f"{entity_name} - MAYA", + MAYA_TYPE_ID, + revision_id=f"rev-{step_name}", + ) + ] if is_published else [] ) @@ -201,3 +208,62 @@ def raise_error(_project_id): step_validation, "objects", types.SimpleNamespace(FlowProject=raise_error) ) assert find_unpublished() is None + + +def find_upstream(step="Surfacing", entity_type="Asset", dependencies=None): + """Call the referencing resolver with the common set of arguments.""" + return step_validation.find_upstream_workfile( + am_project_id="am-project-1", + sg_entity_type=entity_type, + sg_entity_name=ENTITY_NAME, + sg_pipeline_step=step, + workfile_type=MAYA_TYPE, + step_dependencies=( + {"Surfacing": "Model"} if dependencies is None else dependencies + ), + ) + + +def test_find_upstream_workfile_returns_published_asset(flow_am): + """The upstream step's published workfile asset is returned for referencing.""" + flow_am(build_project({"Model": True})) + workfile = find_upstream() + assert workfile is not None + assert workfile.revision_id == "rev-Model" + + +def test_find_upstream_workfile_none_when_unpublished(flow_am): + """Nothing to reference when the upstream step has no publish.""" + flow_am(build_project({"Model": False})) + assert find_upstream() is None + + +def test_find_upstream_workfile_none_without_configured_upstream(flow_am): + """Steps absent from the mapping resolve no reference.""" + flow_am(build_project({"Model": True})) + assert find_upstream(dependencies={}) is None + + +def test_find_upstream_workfile_none_for_unsupported_entity(flow_am): + """An entity type with no Flow AM folder resolves no reference.""" + flow_am(build_project({"Model": True})) + assert find_upstream(entity_type="CustomEntity01") is None + + +def test_find_upstream_workfile_none_when_type_unresolved(flow_am): + """An unresolved schema id would match every child, so skip referencing.""" + flow_am(build_project({"Model": True}), resolve_type_id=False) + assert find_upstream() is None + + +def test_find_upstream_workfile_none_on_flow_am_error(monkeypatch, flow_am): + """A Flow AM outage skips referencing rather than surfacing an error.""" + + def raise_error(_project_id): + raise step_validation.exceptions.FlowError("simulated Flow AM outage") + + flow_am(build_project({"Model": True})) + monkeypatch.setattr( + step_validation, "objects", types.SimpleNamespace(FlowProject=raise_error) + ) + assert find_upstream() is None From 52dee48e288f01c6d54e19d1099da6bb6c429250 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio Date: Wed, 26 Aug 2026 12:33:24 -0500 Subject: [PATCH 3/4] SG-44786 Show action descriptions as context-menu tooltips Custom loader actions defined their description but only the caption reached the UI. Surface the description as a tooltip, mirroring the built-in Refresh action (setToolTip plus driving the hovered signal, since QMenu shows no action tooltips on its own). Expand the Maya Build New Scene description to explain the new referencing/validation behavior driven by pipeline_step_dependencies. Co-authored-by: Cursor --- hooks/tk-maya_actions.py | 14 +++++++++++++- python/tk_multi_loader/dialog.py | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/hooks/tk-maya_actions.py b/hooks/tk-maya_actions.py index 5f3acb7..10a3e6b 100644 --- a/hooks/tk-maya_actions.py +++ b/hooks/tk-maya_actions.py @@ -230,7 +230,19 @@ def generate_actions(self, sg_publish_data, actions, ui_area): "name": "build_new_scene", "params": None, "caption": "Build New Scene", - "description": "This will create a new scene in the current project.", + "description": ( + "Create a new Maya scene for this task in the " + "current Flow AM project.

" + "When this task's pipeline step depends on an upstream " + "step - configured through the " + "pipeline_step_dependencies setting (for " + "example Rig and Texture depend on Model) - the " + "upstream step's published Maya scene is automatically " + "referenced into the new scene.

" + "If that upstream step has not been published yet, you " + "are warned and can still choose to build an empty " + "scene." + ), } ) diff --git a/python/tk_multi_loader/dialog.py b/python/tk_multi_loader/dialog.py index 8ced20f..38ec223 100644 --- a/python/tk_multi_loader/dialog.py +++ b/python/tk_multi_loader/dialog.py @@ -2245,6 +2245,12 @@ def on_action_click(act): ) action = QtGui.QAction(entity_action["caption"], view) + description = entity_action.get("description") + if description: + # QMenu does not show action tooltips on its own, so drive it + # from the hovered signal like the built-in actions above. + action.setToolTip(description) + action.hovered.connect(partial(action_hovered, action)) action.triggered.connect(partial(on_action_click, act=entity_action)) view.addAction(action) self._dynamic_widgets.append(action) From e77697e8fb961dda661183e1f3407f2d083e1695 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio Date: Thu, 27 Aug 2026 12:08:55 -0500 Subject: [PATCH 4/4] Format --- python/tk_multi_loader/flowam/step_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tk_multi_loader/flowam/step_validation.py b/python/tk_multi_loader/flowam/step_validation.py index 954effd..cd6c214 100644 --- a/python/tk_multi_loader/flowam/step_validation.py +++ b/python/tk_multi_loader/flowam/step_validation.py @@ -194,7 +194,7 @@ def find_upstream_workfile( ) except exceptions.FlowError as exc: logger.warning( - f'Could not resolve a published workfile for pipeline step ' + f"Could not resolve a published workfile for pipeline step " f'"{upstream_step}" of "{sg_entity_name}". Skipping referencing. ({exc})' ) return None