diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 96e768e420..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- -**Running version** -[ex. 3.14.1-nightly.2] - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. windows] - - Host: [e.g. Maya, Nuke, Houdini] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..c4073ed1af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,183 @@ +name: Bug Report +description: File a bug report +title: 'Bug: ' +labels: + - 'type: bug' +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: >- + Please search to see if an issue already exists for the bug you + encountered. + options: + - label: I have searched the existing issues + required: true + - type: textarea + attributes: + label: 'Current Behavior:' + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + attributes: + label: 'Expected Behavior:' + description: A concise description of what you expected to happen. + validations: + required: false + - type: dropdown + id: _version + attributes: + label: Version + description: What version are you running? Look to OpenPype Tray + options: + - 3.15.4-nightly.3 + - 3.15.4-nightly.2 + - 3.15.4-nightly.1 + - 3.15.3 + - 3.15.3-nightly.4 + - 3.15.3-nightly.3 + - 3.15.3-nightly.2 + - 3.15.3-nightly.1 + - 3.15.2 + - 3.15.2-nightly.6 + - 3.15.2-nightly.5 + - 3.15.2-nightly.4 + - 3.15.2-nightly.3 + - 3.15.2-nightly.2 + - 3.15.2-nightly.1 + - 3.15.1 + - 3.15.1-nightly.6 + - 3.15.1-nightly.5 + - 3.15.1-nightly.4 + - 3.15.1-nightly.3 + - 3.15.1-nightly.2 + - 3.15.1-nightly.1 + - 3.15.0 + - 3.15.0-nightly.1 + - 3.14.11-nightly.4 + - 3.14.11-nightly.3 + - 3.14.11-nightly.2 + - 3.14.11-nightly.1 + - 3.14.10 + - 3.14.10-nightly.9 + - 3.14.10-nightly.8 + - 3.14.10-nightly.7 + - 3.14.10-nightly.6 + - 3.14.10-nightly.5 + - 3.14.10-nightly.4 + - 3.14.10-nightly.3 + - 3.14.10-nightly.2 + - 3.14.10-nightly.1 + - 3.14.9 + - 3.14.9-nightly.5 + - 3.14.9-nightly.4 + - 3.14.9-nightly.3 + - 3.14.9-nightly.2 + - 3.14.9-nightly.1 + - 3.14.8 + - 3.14.8-nightly.4 + - 3.14.8-nightly.3 + - 3.14.8-nightly.2 + - 3.14.8-nightly.1 + - 3.14.7 + - 3.14.7-nightly.8 + - 3.14.7-nightly.7 + - 3.14.7-nightly.6 + - 3.14.7-nightly.5 + - 3.14.7-nightly.4 + - 3.14.7-nightly.3 + - 3.14.7-nightly.2 + - 3.14.7-nightly.1 + - 3.14.6 + - 3.14.6-nightly.3 + - 3.14.6-nightly.2 + - 3.14.6-nightly.1 + - 3.14.5 + - 3.14.5-nightly.3 + - 3.14.5-nightly.2 + - 3.14.5-nightly.1 + - 3.14.4 + - 3.14.4-nightly.4 + - 3.14.4-nightly.3 + - 3.14.4-nightly.2 + - 3.14.4-nightly.1 + - 3.14.3 + - 3.14.3-nightly.7 + - 3.14.3-nightly.6 + - 3.14.3-nightly.5 + - 3.14.3-nightly.4 + - 3.14.3-nightly.3 + - 3.14.3-nightly.2 + - 3.14.3-nightly.1 + - 3.14.2 + - 3.14.2-nightly.5 + - 3.14.2-nightly.4 + - 3.14.2-nightly.3 + - 3.14.2-nightly.2 + - 3.14.2-nightly.1 + - 3.14.1 + - 3.14.1-nightly.4 + - 3.14.1-nightly.3 + - 3.14.1-nightly.2 + - 3.14.1-nightly.1 + - 3.14.0 + - 3.14.0-nightly.1 + - 3.13.1-nightly.3 + - 3.13.1-nightly.2 + - 3.13.1-nightly.1 + - 3.13.0 + - 3.13.0-nightly.1 + - 3.12.3-nightly.3 + - 3.12.3-nightly.2 + - 3.12.3-nightly.1 + validations: + required: true + - type: dropdown + validations: + required: true + attributes: + label: What platform you are running OpenPype on? + description: | + Please specify the operating systems you are running OpenPype with. + multiple: true + options: + - Windows + - Linux / Centos + - Linux / Ubuntu + - Linux / RedHat + - MacOS + - type: textarea + id: to-reproduce + attributes: + label: 'Steps To Reproduce:' + description: Steps to reproduce the behavior. + placeholder: | + 1. How did the configuration look like + 2. What type of action was made + validations: + required: true + - type: checkboxes + attributes: + label: Are there any labels you wish to add? + description: Please search labels and identify those related to your bug. + options: + - label: I have added the relevant labels to the bug report. + required: true + - type: textarea + id: logs + attributes: + label: 'Relevant log output:' + description: >- + Please copy and paste any relevant log output. This will be + automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + id: additional-context + attributes: + label: 'Additional context:' + description: Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..a2896f77de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Ynput Discord Server + url: https://discord.gg/ynput + about: For community quick chats. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/enhancement_request.yml b/.github/ISSUE_TEMPLATE/enhancement_request.yml new file mode 100644 index 0000000000..52b49e0481 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement_request.yml @@ -0,0 +1,52 @@ +name: Enhancement Request +description: Create a report to help us enhance a particular feature +title: "Enhancement: " +labels: + - "type: enhancement" +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this enhancement request report! + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues. + required: true + - type: textarea + id: related-feature + attributes: + label: Please describe the feature you have in mind and explain what the current shortcomings are? + description: A clear and concise description of what the problem is. + validations: + required: true + - type: textarea + id: enhancement-proposal + attributes: + label: How would you imagine the implementation of the feature? + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: checkboxes + attributes: + label: Are there any labels you wish to add? + description: Please search labels and identify those related to your enhancement. + options: + - label: I have added the relevant labels to the enhancement request. + required: true + - type: textarea + id: alternatives + attributes: + label: "Describe alternatives you've considered:" + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: "Additional context:" + description: Add any other context or screenshots about the enhancement request here. + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 11fc491ef1..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f78e95528f..f2e7d1058f 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,4 +1,4 @@ -name: documentation +name: 📜 Documentation on: pull_request: diff --git a/.github/workflows/milestone_assign.yml b/.github/workflows/milestone_assign.yml index 3cbee51472..df4625c225 100644 --- a/.github/workflows/milestone_assign.yml +++ b/.github/workflows/milestone_assign.yml @@ -1,4 +1,4 @@ -name: Milestone - assign to PRs +name: 👉🏻 Milestone - assign to PRs on: pull_request_target: diff --git a/.github/workflows/milestone_create.yml b/.github/workflows/milestone_create.yml index 632704e64a..437c9e31b4 100644 --- a/.github/workflows/milestone_create.yml +++ b/.github/workflows/milestone_create.yml @@ -1,4 +1,4 @@ -name: Milestone - create default +name: ➕ Milestone - create default on: milestone: diff --git a/.github/workflows/miletone_release_trigger.yml b/.github/workflows/miletone_release_trigger.yml index b5b8aab1dc..26a2d5833d 100644 --- a/.github/workflows/miletone_release_trigger.yml +++ b/.github/workflows/miletone_release_trigger.yml @@ -1,4 +1,4 @@ -name: Milestone Release [trigger] +name: 🚩 Milestone Release [trigger] on: workflow_dispatch: diff --git a/.github/workflows/nightly_merge.yml b/.github/workflows/nightly_merge.yml index 1776d7a464..f1850762d9 100644 --- a/.github/workflows/nightly_merge.yml +++ b/.github/workflows/nightly_merge.yml @@ -1,4 +1,4 @@ -name: Dev -> Main +name: 🔀 Dev -> Main on: schedule: diff --git a/.github/workflows/pr_labels.yml b/.github/workflows/pr_labels.yml new file mode 100644 index 0000000000..ecc95051aa --- /dev/null +++ b/.github/workflows/pr_labels.yml @@ -0,0 +1,49 @@ +name: 🔖 PR labels + +on: + pull_request_target: + types: [opened, assigned] + +jobs: + size-label: + name: pr_size_label + runs-on: ubuntu-latest + if: github.event.action == 'assigned' || github.event.action == 'opened' + steps: + - name: Add size label + uses: "pascalgn/size-label-action@v0.4.3" + env: + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" + IGNORED: ".gitignore\n*.md\n*.json" + with: + sizes: > + { + "0": "XS", + "100": "S", + "500": "M", + "1000": "L", + "1500": "XL", + "2500": "XXL" + } + + label_prs_branch: + name: pr_branch_label + runs-on: ubuntu-latest + if: github.event.action == 'assigned' || github.event.action == 'opened' + steps: + - name: Label PRs - Branch name detection + uses: ffittschen/pr-branch-labeler@v1 + with: + repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} + + label_prs_globe: + name: pr_globe_label + runs-on: ubuntu-latest + if: github.event.action == 'assigned' || github.event.action == 'opened' + steps: + - name: Label PRs - Globe detection + uses: actions/labeler@v4.0.3 + with: + repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} + configuration-path: ".github/pr-glob-labeler.yml" + sync-labels: false diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 571b0339e1..e8c619c6eb 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1,4 +1,4 @@ -name: Nightly Prerelease +name: ⏳ Nightly Prerelease on: workflow_dispatch: diff --git a/.github/workflows/project_actions.yml b/.github/workflows/project_task_statuses.yml similarity index 68% rename from .github/workflows/project_actions.yml rename to .github/workflows/project_task_statuses.yml index 3589b4acc2..d078c08b70 100644 --- a/.github/workflows/project_actions.yml +++ b/.github/workflows/project_task_statuses.yml @@ -1,8 +1,6 @@ -name: project-actions +name: 📊 Project task statuses on: - pull_request_target: - types: [opened, assigned] pull_request_review: types: [submitted] issue_comment: @@ -70,46 +68,3 @@ jobs: -d '{ "status": "in progress" }' - - size-label: - name: pr_size_label - runs-on: ubuntu-latest - if: github.event.action == 'assigned' || github.event.action == 'opened' - steps: - - name: Add size label - uses: "pascalgn/size-label-action@v0.4.3" - env: - GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" - IGNORED: ".gitignore\n*.md\n*.json" - with: - sizes: > - { - "0": "XS", - "100": "S", - "500": "M", - "1000": "L", - "1500": "XL", - "2500": "XXL" - } - - label_prs_branch: - name: pr_branch_label - runs-on: ubuntu-latest - if: github.event.action == 'assigned' || github.event.action == 'opened' - steps: - - name: Label PRs - Branch name detection - uses: ffittschen/pr-branch-labeler@v1 - with: - repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} - - label_prs_globe: - name: pr_globe_label - runs-on: ubuntu-latest - if: github.event.action == 'assigned' || github.event.action == 'opened' - steps: - - name: Label PRs - Globe detection - uses: actions/labeler@v4.0.3 - with: - repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} - configuration-path: ".github/pr-glob-labeler.yml" - sync-labels: false diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index 064a4d47e0..fd8e0e642d 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -1,7 +1,7 @@ # This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries -name: Test Build +name: 🏗️ Test Build on: pull_request: diff --git a/.github/workflows/update_bug_report.yml b/.github/workflows/update_bug_report.yml new file mode 100644 index 0000000000..9f44d7c7a6 --- /dev/null +++ b/.github/workflows/update_bug_report.yml @@ -0,0 +1,25 @@ +name: 🐞 Update Bug Report + +on: + workflow_dispatch: + release: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release + types: [published] + +jobs: + update-bug-report: + runs-on: ubuntu-latest + name: Update bug report + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.release.target_commitish }} + - name: Update version + uses: ynput/gha-populate-form-version@main + with: + github_token: ${{ secrets.YNPUT_BOT_TOKEN }} + registry: github + dropdown: _version + limit_to: 100 + form: .github/ISSUE_TEMPLATE/bug_report.yml + commit_message: 'chore(): update bug report / version' diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 8048705dc8..b009dabb44 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -242,9 +242,15 @@ def launch_zip_file(filepath): print(f"Localizing {filepath}") temp_path = get_local_harmony_path(filepath) + scene_name = os.path.basename(temp_path) + if os.path.exists(os.path.join(temp_path, scene_name)): + # unzipped with duplicated scene_name + temp_path = os.path.join(temp_path, scene_name) + scene_path = os.path.join( - temp_path, os.path.basename(temp_path) + ".xstage" + temp_path, scene_name + ".xstage" ) + unzip = False if os.path.exists(scene_path): # Check remote scene is newer than local. @@ -262,6 +268,10 @@ def launch_zip_file(filepath): with _ZipFile(filepath, "r") as zip_ref: zip_ref.extractall(temp_path) + if os.path.exists(os.path.join(temp_path, scene_name)): + # unzipped with duplicated scene_name + temp_path = os.path.join(temp_path, scene_name) + # Close existing scene. if ProcessContext.pid: os.kill(ProcessContext.pid, signal.SIGTERM) @@ -309,7 +319,7 @@ def launch_zip_file(filepath): ) if not os.path.exists(scene_path): - print("error: cannot determine scene file") + print("error: cannot determine scene file {}".format(scene_path)) ProcessContext.server.stop() return diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py new file mode 100644 index 0000000000..3638e14296 --- /dev/null +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -0,0 +1,185 @@ +"""Library to register OpenPype Creators for Houdini TAB node search menu. + +This can be used to install custom houdini tools for the TAB search +menu which will trigger a publish instance to be created interactively. + +The Creators are automatically registered on launch of Houdini through the +Houdini integration's `host.install()` method. + +""" +import contextlib +import tempfile +import logging +import os + +from openpype.pipeline import registered_host +from openpype.pipeline.create import CreateContext +from openpype.resources import get_openpype_icon_filepath + +import hou + +log = logging.getLogger(__name__) + +CREATE_SCRIPT = """ +from openpype.hosts.houdini.api.creator_node_shelves import create_interactive +create_interactive("{identifier}") +""" + + +def create_interactive(creator_identifier): + """Create a Creator using its identifier interactively. + + This is used by the generated shelf tools as callback when a user selects + the creator from the node tab search menu. + + Args: + creator_identifier (str): The creator identifier of the Creator plugin + to create. + + Return: + list: The created instances. + + """ + + # TODO Use Qt instead + result, variant = hou.ui.readInput('Define variant name', + buttons=("Ok", "Cancel"), + initial_contents='Main', + title="Define variant", + help="Set the variant for the " + "publish instance", + close_choice=1) + if result == 1: + # User interrupted + return + variant = variant.strip() + if not variant: + raise RuntimeError("Empty variant value entered.") + + host = registered_host() + context = CreateContext(host) + + before = context.instances_by_id.copy() + + # Create the instance + context.create( + creator_identifier=creator_identifier, + variant=variant, + pre_create_data={"use_selection": True} + ) + + # For convenience we set the new node as current since that's much more + # familiar to the artist when creating a node interactively + # TODO Allow to disable auto-select in studio settings or user preferences + after = context.instances_by_id + new = set(after) - set(before) + if new: + # Select the new instance + for instance_id in new: + instance = after[instance_id] + node = hou.node(instance.get("instance_node")) + node.setCurrent(True) + + return list(new) + + +@contextlib.contextmanager +def shelves_change_block(): + """Write shelf changes at the end of the context.""" + hou.shelves.beginChangeBlock() + try: + yield + finally: + hou.shelves.endChangeBlock() + + +def install(): + """Install the Creator plug-ins to show in Houdini's TAB node search menu. + + This function is re-entrant and can be called again to reinstall and + update the node definitions. For example during development it can be + useful to call it manually: + >>> from openpype.hosts.houdini.api.creator_node_shelves import install + >>> install() + + Returns: + list: List of `hou.Tool` instances + + """ + + host = registered_host() + + # Store the filepath on the host + # TODO: Define a less hacky static shelf path for current houdini session + filepath_attr = "_creator_node_shelf_filepath" + filepath = getattr(host, filepath_attr, None) + if filepath is None: + f = tempfile.NamedTemporaryFile(prefix="houdini_creator_nodes_", + suffix=".shelf", + delete=False) + f.close() + filepath = f.name + setattr(host, filepath_attr, filepath) + elif os.path.exists(filepath): + # Remove any existing shelf file so that we can completey regenerate + # and update the tools file if creator identifiers change + os.remove(filepath) + + icon = get_openpype_icon_filepath() + + # 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 + create_context = CreateContext(host, reset=False) + create_context.reset_current_context() + create_context._reset_creator_plugins() + + log.debug("Writing OpenPype Creator nodes to shelf: {}".format(filepath)) + tools = [] + with shelves_change_block(): + for identifier, creator in create_context.manual_creators.items(): + + # TODO: Allow the creator plug-in itself to override the categories + # for where they are shown, by e.g. defining + # `Creator.get_network_categories()` + + key = "openpype_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( + creator.label + ), + "help_url": None, + "network_categories": [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ], + "viewer_categories": [], + "cop_viewer_categories": [], + "network_op_type": None, + "viewer_op_type": None, + "locations": ["OpenPype"] + } + + label = "Create {}".format(creator.label) + tool = hou.shelves.tool(key) + if tool: + tool.setData(**data) + tool.setLabel(label) + else: + tool = hou.shelves.newTool( + file_path=filepath, + name=key, + label=label, + **data + ) + + tools.append(tool) + + # Ensure the shelf is reloaded + hou.shelves.loadFile(filepath) + + return tools diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index f19dc64992..2e58f3dd98 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -127,6 +127,8 @@ def get_output_parameter(node): return node.parm("filename") elif node_type == "comp": return node.parm("copoutput") + elif node_type == "opengl": + return node.parm("picture") elif node_type == "arnold": if node.evalParm("ar_ass_export_enable"): return node.parm("ar_ass_file") diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 45e2f8f87f..61274e6028 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -18,7 +18,7 @@ from openpype.pipeline import ( ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.houdini import HOUDINI_HOST_DIR -from openpype.hosts.houdini.api import lib, shelves +from openpype.hosts.houdini.api import lib, shelves, creator_node_shelves from openpype.lib import ( register_event_callback, @@ -83,6 +83,10 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): _set_context_settings() shelves.generate_shelves() + if not IS_HEADLESS: + import hdefereval # noqa, hdefereval is only available in ui mode + hdefereval.executeDeferred(creator_node_shelves.install) + def has_unsaved_changes(self): return hou.hipFile.hasUnsavedChanges() diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py new file mode 100644 index 0000000000..ab06b30c35 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating openGL reviews.""" +from openpype.hosts.houdini.api import plugin +from openpype.lib import EnumDef, BoolDef, NumberDef + + +class CreateReview(plugin.HoudiniCreator): + """Review with OpenGL ROP""" + + identifier = "io.openpype.creators.houdini.review" + label = "Review" + family = "review" + icon = "video-camera" + + def create(self, subset_name, instance_data, pre_create_data): + import hou + + instance_data.pop("active", None) + instance_data.update({"node_type": "opengl"}) + instance_data["imageFormat"] = pre_create_data.get("imageFormat") + instance_data["keepImages"] = pre_create_data.get("keepImages") + + instance = super(CreateReview, self).create( + subset_name, + instance_data, + pre_create_data) + + instance_node = hou.node(instance.get("instance_node")) + + frame_range = hou.playbar.frameRange() + + filepath = "{root}/{subset}/{subset}.$F4.{ext}".format( + root=hou.text.expandString("$HIP/pyblish"), + subset="`chs(\"subset\")`", # keep dynamic link to subset + ext=pre_create_data.get("image_format") or "png" + ) + + parms = { + "picture": filepath, + + "trange": 1, + + # Unlike many other ROP nodes the opengl node does not default + # to expression of $FSTART and $FEND so we preserve that behavior + # but do set the range to the frame range of the playbar + "f1": frame_range[0], + "f2": frame_range[1], + } + + override_resolution = pre_create_data.get("override_resolution") + if override_resolution: + parms.update({ + "tres": override_resolution, + "res1": pre_create_data.get("resx"), + "res2": pre_create_data.get("resy"), + "aspect": pre_create_data.get("aspect"), + }) + + if self.selected_nodes: + # The first camera found in selection we will use as camera + # Other node types we set in force objects + camera = None + force_objects = [] + for node in self.selected_nodes: + path = node.path() + if node.type().name() == "cam": + if camera: + continue + camera = path + else: + force_objects.append(path) + + if not camera: + self.log.warning("No camera found in selection.") + + parms.update({ + "camera": camera or "", + "scenepath": "/obj", + "forceobjects": " ".join(force_objects), + "vobjects": "" # clear candidate objects from '*' value + }) + + instance_node.setParms(parms) + + to_lock = ["id", "family"] + + self.lock_parameters(instance_node, to_lock) + + def get_pre_create_attr_defs(self): + attrs = super(CreateReview, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + BoolDef("keepImages", + label="Keep Image Sequences", + default=False), + EnumDef("imageFormat", + image_format_enum, + default="png", + label="Image Format Options"), + BoolDef("override_resolution", + label="Override resolution", + tooltip="When disabled the resolution set on the camera " + "is used instead.", + default=True), + NumberDef("resx", + label="Resolution Width", + default=1280, + minimum=2, + decimals=0), + NumberDef("resy", + label="Resolution Height", + default=720, + minimum=2, + decimals=0), + NumberDef("aspect", + label="Aspect Ratio", + default=1.0, + minimum=0.0001, + decimals=3) + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 531cdf1249..6c695f64e9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -14,7 +14,7 @@ class CollectFrames(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder label = "Collect Frames" - families = ["vdbcache", "imagesequence", "ass", "redshiftproxy"] + families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review"] def process(self, instance): diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py new file mode 100644 index 0000000000..e321dcb2fa --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py @@ -0,0 +1,52 @@ +import hou +import pyblish.api + + +class CollectHoudiniReviewData(pyblish.api.InstancePlugin): + """Collect Review Data.""" + + label = "Collect Review Data" + order = pyblish.api.CollectorOrder + 0.1 + hosts = ["houdini"] + families = ["review"] + + def process(self, instance): + + # This fixes the burnin having the incorrect start/end timestamps + # because without this it would take it from the context instead + # which isn't the actual frame range that this instance renders. + instance.data["handleStart"] = 0 + instance.data["handleEnd"] = 0 + + # Get the camera from the rop node to collect the focal length + ropnode_path = instance.data["instance_node"] + ropnode = hou.node(ropnode_path) + + camera_path = ropnode.parm("camera").eval() + camera_node = hou.node(camera_path) + if not camera_node: + raise RuntimeError("No valid camera node found on review node: " + "{}".format(camera_path)) + + # Collect focal length. + focal_length_parm = camera_node.parm("focal") + if not focal_length_parm: + self.log.warning("No 'focal' (focal length) parameter found on " + "camera: {}".format(camera_path)) + return + + if focal_length_parm.isTimeDependent(): + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + 1 + focal_length = [ + focal_length_parm.evalAsFloatAtFrame(t) + for t in range(int(start), int(end)) + ] + else: + focal_length = focal_length_parm.evalAsFloat() + + # Store focal length in `burninDataMembers` + burnin_members = instance.data.setdefault("burninDataMembers", {}) + burnin_members["focalLength"] = focal_length + + instance.data.setdefault("families", []).append('ftrack') diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py new file mode 100644 index 0000000000..c26d0813a6 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py @@ -0,0 +1,58 @@ +import os + +import pyblish.api + +from openpype.pipeline import ( + publish, + OptionalPyblishPluginMixin +) +from openpype.hosts.houdini.api.lib import render_rop + +import hou + + +class ExtractOpenGL(publish.Extractor, + OptionalPyblishPluginMixin): + + order = pyblish.api.ExtractorOrder - 0.01 + label = "Extract OpenGL" + families = ["review"] + hosts = ["houdini"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + ropnode = hou.node(instance.data.get("instance_node")) + + output = ropnode.evalParm("picture") + staging_dir = os.path.normpath(os.path.dirname(output)) + instance.data["stagingDir"] = staging_dir + file_name = os.path.basename(output) + + self.log.info("Extracting '%s' to '%s'" % (file_name, + staging_dir)) + + render_rop(ropnode) + + output = instance.data["frames"] + + tags = ["review"] + if not instance.data.get("keepImages"): + tags.append("delete") + + representation = { + "name": instance.data["imageFormat"], + "ext": instance.data["imageFormat"], + "files": output, + "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], + "tags": tags, + "preview": True, + "camera_name": instance.data.get("review_camera") + } + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_scene_review.py b/openpype/hosts/houdini/plugins/publish/validate_scene_review.py new file mode 100644 index 0000000000..ade01d4b90 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_scene_review.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +import hou + + +class ValidateSceneReview(pyblish.api.InstancePlugin): + """Validator Some Scene Settings before publishing the review + 1. Scene Path + 2. Resolution + """ + + order = pyblish.api.ValidatorOrder + families = ["review"] + hosts = ["houdini"] + label = "Scene Setting for review" + + def process(self, instance): + invalid = self.get_invalid_scene_path(instance) + + report = [] + if invalid: + report.append( + "Scene path does not exist: '%s'" % invalid[0], + ) + + invalid = self.get_invalid_resolution(instance) + if invalid: + report.extend(invalid) + + if report: + raise PublishValidationError( + "\n\n".join(report), + title=self.label) + + def get_invalid_scene_path(self, instance): + + node = hou.node(instance.data.get("instance_node")) + scene_path_parm = node.parm("scenepath") + scene_path_node = scene_path_parm.evalAsNode() + if not scene_path_node: + return [scene_path_parm.evalAsString()] + + def get_invalid_resolution(self, instance): + node = hou.node(instance.data.get("instance_node")) + + # The resolution setting is only used when Override Camera Resolution + # is enabled. So we skip validation if it is disabled. + override = node.parm("tres").eval() + if not override: + return + + invalid = [] + res_width = node.parm("res1").eval() + res_height = node.parm("res2").eval() + if res_width == 0: + invalid.append("Override Resolution width is set to zero.") + if res_height == 0: + invalid.append("Override Resolution height is set to zero") + + return invalid diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 63e4108c84..b040467522 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -62,6 +62,7 @@ class CollectRender(pyblish.api.InstancePlugin): "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], "version": version_int, + "farm": True } self.log.info("data: {0}".format(data)) instance.data.update(data) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 22803a2e3a..f6fcab7e40 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -32,7 +32,12 @@ from openpype.pipeline import ( load_container, registered_host, ) -from openpype.pipeline.context_tools import get_current_project_asset +from openpype.pipeline.context_tools import ( + get_current_asset_name, + get_current_project_asset, + get_current_project_name, + get_current_task_name +) self = sys.modules[__name__] @@ -292,15 +297,20 @@ def collect_animation_data(fps=False): """ # get scene values as defaults - start = cmds.playbackOptions(query=True, animationStartTime=True) - end = cmds.playbackOptions(query=True, animationEndTime=True) + frame_start = cmds.playbackOptions(query=True, minTime=True) + frame_end = cmds.playbackOptions(query=True, maxTime=True) + handle_start = cmds.playbackOptions(query=True, animationStartTime=True) + handle_end = cmds.playbackOptions(query=True, animationEndTime=True) + + handle_start = frame_start - handle_start + handle_end = handle_end - frame_end # build attributes data = OrderedDict() - data["frameStart"] = start - data["frameEnd"] = end - data["handleStart"] = 0 - data["handleEnd"] = 0 + data["frameStart"] = frame_start + data["frameEnd"] = frame_end + data["handleStart"] = handle_start + data["handleEnd"] = handle_end data["step"] = 1.0 if fps: @@ -2134,9 +2144,13 @@ def get_frame_range(): """Get the current assets frame range and handles.""" # Set frame start/end - project_name = legacy_io.active_project() - asset_name = legacy_io.Session["AVALON_ASSET"] + project_name = get_current_project_name() + task_name = get_current_task_name() + asset_name = get_current_asset_name() asset = get_asset_by_name(project_name, asset_name) + settings = get_project_settings(project_name) + include_handles_settings = settings["maya"]["include_handles"] + current_task = asset.get("data").get("tasks").get(task_name) frame_start = asset["data"].get("frameStart") frame_end = asset["data"].get("frameEnd") @@ -2148,6 +2162,26 @@ def get_frame_range(): handle_start = asset["data"].get("handleStart") or 0 handle_end = asset["data"].get("handleEnd") or 0 + animation_start = frame_start + animation_end = frame_end + + include_handles = include_handles_settings["include_handles_default"] + for item in include_handles_settings["per_task_type"]: + if current_task["type"] in item["task_type"]: + include_handles = item["include_handles"] + break + if include_handles: + animation_start -= int(handle_start) + animation_end += int(handle_end) + + cmds.playbackOptions( + minTime=frame_start, + maxTime=frame_end, + animationStartTime=animation_start, + animationEndTime=animation_end + ) + cmds.currentTime(frame_start) + return { "frameStart": frame_start, "frameEnd": frame_end, @@ -2166,7 +2200,6 @@ def reset_frame_range(playback=True, render=True, fps=True): Defaults to True. fps (bool, Optional): Whether to set scene FPS. Defaults to True. """ - if fps: fps = convert_to_maya_fps( float(legacy_io.Session.get("AVALON_FPS", 25)) @@ -3655,7 +3688,17 @@ def get_color_management_preferences(): # Split view and display from view_transform. view_transform comes in # format of "{view} ({display})". regex = re.compile(r"^(?P.+) \((?P.+)\)$") + if int(cmds.about(version=True)) <= 2020: + # view_transform comes in format of "{view} {display}" in 2020. + regex = re.compile(r"^(?P.+) (?P.+)$") + match = regex.match(data["view_transform"]) + if not match: + raise ValueError( + "Unable to parse view and display from Maya view transform: '{}' " + "using regex '{}'".format(data["view_transform"], regex.pattern) + ) + data.update({ "display": match.group("display"), "view": match.group("view") diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 916fddd923..714278ba6c 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -1,4 +1,5 @@ import os +import re from maya import cmds @@ -12,6 +13,7 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, Anatomy, ) +from openpype.pipeline.load import LoadError from openpype.settings import get_project_settings from .pipeline import containerise from . import lib @@ -82,6 +84,44 @@ def get_reference_node_parents(ref): return parents +def get_custom_namespace(custom_namespace): + """Return unique namespace. + + The input namespace can contain a single group + of '#' number tokens to indicate where the namespace's + unique index should go. The amount of tokens defines + the zero padding of the number, e.g ### turns into 001. + + Warning: Note that a namespace will always be + prefixed with a _ if it starts with a digit + + Example: + >>> get_custom_namespace("myspace_##_") + # myspace_01_ + >>> get_custom_namespace("##_myspace") + # _01_myspace + >>> get_custom_namespace("myspace##") + # myspace01 + + """ + split = re.split("([#]+)", custom_namespace, 1) + + if len(split) == 3: + base, padding, suffix = split + padding = "%0{}d".format(len(padding)) + else: + base = split[0] + padding = "%02d" # default padding + suffix = "" + + return lib.unique_namespace( + base, + format=padding, + prefix="_" if not base or base[0].isdigit() else "", + suffix=suffix + ) + + class Creator(LegacyCreator): defaults = ['Main'] @@ -143,15 +183,46 @@ class ReferenceLoader(Loader): assert os.path.exists(self.fname), "%s does not exist." % self.fname asset = context['asset'] + subset = context['subset'] + settings = get_project_settings(context['project']['name']) + custom_naming = settings['maya']['load']['reference_loader'] loaded_containers = [] - count = options.get("count") or 1 - for c in range(0, count): - namespace = namespace or lib.unique_namespace( - "{}_{}_".format(asset["name"], context["subset"]["name"]), - prefix="_" if asset["name"][0].isdigit() else "", - suffix="_", + if not custom_naming['namespace']: + raise LoadError("No namespace specified in " + "Maya ReferenceLoader settings") + elif not custom_naming['group_name']: + raise LoadError("No group name specified in " + "Maya ReferenceLoader settings") + + formatting_data = { + "asset_name": asset['name'], + "asset_type": asset['type'], + "subset": subset['name'], + "family": ( + subset['data'].get('family') or + subset['data']['families'][0] ) + } + + custom_namespace = custom_naming['namespace'].format( + **formatting_data + ) + + custom_group_name = custom_naming['group_name'].format( + **formatting_data + ) + + count = options.get("count") or 1 + + for c in range(0, count): + namespace = get_custom_namespace(custom_namespace) + group_name = "{}:{}".format( + namespace, + custom_group_name + ) + + options['group_name'] = group_name # Offset loaded subset if "offset" in options: @@ -187,7 +258,7 @@ class ReferenceLoader(Loader): return loaded_containers - def process_reference(self, context, name, namespace, data): + def process_reference(self, context, name, namespace, options): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index b419a730b5..2ba5fe6b64 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -14,7 +14,7 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): icon = "code-fork" color = "orange" - def process_reference(self, context, name, namespace, data): + def process_reference(self, context, name, namespace, options): import maya.cmds as cmds from openpype.hosts.maya.api.lib import unique_namespace @@ -41,7 +41,7 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): namespace=namespace, sharedReferenceFile=False, groupReference=True, - groupName="{}:{}".format(namespace, name), + groupName=options['group_name'], reference=True, returnNewNodes=True) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 461f4258aa..c2b321b789 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -125,14 +125,15 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except ValueError: family = "model" - group_name = "{}:_GRP".format(namespace) # True by default to keep legacy behaviours attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) file_url = self.prepare_root_value(self.fname, context["project"]["name"]) + nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index 6a13d2e145..b8066871b0 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -19,8 +19,7 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference( self, context, name=None, namespace=None, options=None ): - - group_name = "{}:{}".format(namespace, name) + group_name = options['group_name'] with lib.maintained_selection(): file_url = self.prepare_root_value( self.fname, context["project"]["name"] diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 93054e5fbb..520951a5e6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -26,7 +26,7 @@ HARDLINK = 2 @attr.s -class TextureResult: +class TextureResult(object): """The resulting texture of a processed file for a resource""" # Path to the file path = attr.ib() diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 74269cc506..7dd66eed6c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -255,7 +255,7 @@ class ValidateMeshHasOverlappingUVs(pyblish.api.InstancePlugin): # Store original uv set original_current_uv_set = cmds.polyUVSet(mesh, query=True, - currentUVSet=True) + currentUVSet=True)[0] overlapping_faces = [] for uv_set in cmds.polyUVSet(mesh, query=True, allUVSets=True): diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 157a02b9aa..fe3a2d2bd1 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -23,6 +23,9 @@ from openpype.client import ( from openpype.host import HostDirmap from openpype.tools.utils import host_tools +from openpype.pipeline.workfile.workfile_template_builder import ( + TemplateProfileNotFound +) from openpype.lib import ( env_value_to_bool, Logger, @@ -2684,7 +2687,10 @@ def start_workfile_template_builder(): # to avoid looping of the callback, remove it! log.info("Starting workfile template builder...") - build_workfile_template(workfile_creation_enabled=True) + try: + build_workfile_template(workfile_creation_enabled=True) + except TemplateProfileNotFound: + log.warning("Template profile not found. Skipping...") # remove callback since it would be duplicating the workfile nuke.removeOnCreate(start_workfile_template_builder, nodeClass="Root") diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index cf85a5ea05..72d4ffb476 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -219,14 +219,17 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): # fix the problem of z_order for backdrops self._fix_z_order(placeholder) - self._imprint_siblings(placeholder) + + if placeholder.data.get("keep_placeholder"): + self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: # save initial nodes positions and dimensions, update them # and set inputs and outputs of loaded nodes + if placeholder.data.get("keep_placeholder"): + self._imprint_inits() + self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded) - self._imprint_inits() - self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded) self._set_loaded_connections(placeholder) elif placeholder.data["siblings"]: @@ -629,14 +632,18 @@ class NukePlaceholderCreatePlugin( # fix the problem of z_order for backdrops self._fix_z_order(placeholder) - self._imprint_siblings(placeholder) + + if placeholder.data.get("keep_placeholder"): + self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: # save initial nodes positions and dimensions, update them # and set inputs and outputs of created nodes - self._imprint_inits() - self._update_nodes(placeholder, nuke.allNodes(), nodes_created) + if placeholder.data.get("keep_placeholder"): + self._imprint_inits() + self._update_nodes(placeholder, nuke.allNodes(), nodes_created) + self._set_created_connections(placeholder) elif placeholder.data["siblings"]: diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py b/openpype/hosts/nuke/plugins/publish/validate_asset_name.py index f6822bee45..df05f76a5b 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_name.py @@ -9,9 +9,9 @@ import openpype.hosts.nuke.api.lib as nlib from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, + OptionalPyblishPluginMixin ) - class SelectInvalidInstances(pyblish.api.Action): """Select invalid instances in Outliner.""" @@ -92,7 +92,10 @@ class RepairSelectInvalidInstances(pyblish.api.Action): nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data) -class ValidateCorrectAssetName(pyblish.api.InstancePlugin): +class ValidateCorrectAssetName( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of @@ -111,6 +114,9 @@ class ValidateCorrectAssetName(pyblish.api.InstancePlugin): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + asset = instance.data.get("asset") context_asset = instance.context.data["assetEntity"]["name"] node = instance.data["transientData"]["node"] diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index 5f4a5c3ab0..ad60089952 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -1,8 +1,12 @@ import nuke import pyblish from openpype.hosts.nuke import api as napi -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline.publish import ( + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin +) class SelectCenterInNodeGraph(pyblish.api.Action): """ @@ -46,12 +50,15 @@ class SelectCenterInNodeGraph(pyblish.api.Action): nuke.zoom(2, [min(all_xC), min(all_yC)]) -class ValidateBackdrop(pyblish.api.InstancePlugin): +class ValidateBackdrop( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): """ Validate amount of nodes on backdrop node in case user forgotten to add nodes above the publishing backdrop node. """ - order = pyblish.api.ValidatorOrder + order = ValidateContentsOrder optional = True families = ["nukenodes"] label = "Validate Backdrop" @@ -59,6 +66,9 @@ class ValidateBackdrop(pyblish.api.InstancePlugin): actions = [SelectCenterInNodeGraph] def process(self, instance): + if not self.is_active(instance.data): + return + child_nodes = instance.data["transientData"]["childNodes"] connections_out = instance.data["transientData"]["nodeConnectionsOut"] diff --git a/openpype/hosts/nuke/plugins/publish/validate_script_attributes.py b/openpype/hosts/nuke/plugins/publish/validate_script_attributes.py index bd0bbf8044..57bfce7993 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_script_attributes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_script_attributes.py @@ -18,7 +18,7 @@ class ValidateScriptAttributes( order = pyblish.api.ValidatorOrder + 0.1 families = ["workfile"] - label = "Validatte script attributes" + label = "Validate script attributes" hosts = ["nuke"] optional = True actions = [RepairAction] diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 0f99efb430..9be1736abf 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -256,17 +256,18 @@ class TemplatesDict(object): elif isinstance(templates, dict): self._raw_templates = copy.deepcopy(templates) self._templates = templates - self._objected_templates = self.create_ojected_templates(templates) + self._objected_templates = self.create_objected_templates( + templates) else: raise TypeError("<{}> argument must be a dict, not {}.".format( self.__class__.__name__, str(type(templates)) )) def __getitem__(self, key): - return self.templates[key] + return self.objected_templates[key] def get(self, key, *args, **kwargs): - return self.templates.get(key, *args, **kwargs) + return self.objected_templates.get(key, *args, **kwargs) @property def raw_templates(self): @@ -280,8 +281,21 @@ class TemplatesDict(object): def objected_templates(self): return self._objected_templates - @classmethod - def create_ojected_templates(cls, templates): + def _create_template_object(self, template): + """Create template object from a template string. + + Separated into method to give option change class of templates. + + Args: + template (str): Template string. + + Returns: + StringTemplate: Object of template. + """ + + return StringTemplate(template) + + def create_objected_templates(self, templates): if not isinstance(templates, dict): raise TypeError("Expected dict object, got {}".format( str(type(templates)) @@ -297,7 +311,7 @@ class TemplatesDict(object): for key in tuple(item.keys()): value = item[key] if isinstance(value, six.string_types): - item[key] = StringTemplate(value) + item[key] = self._create_template_object(value) elif isinstance(value, dict): inner_queue.append(value) return objected_templates diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 062732c059..19d4f170b6 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -142,10 +142,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") - job_info.ChunkSize = instance.data.get("chunkSize", 10) job_info.Comment = context.data.get("comment") job_info.Priority = instance.data.get("priority", self.priority) - job_info.FramesPerTask = instance.data.get("framesPerTask", 1) if self.group != "none" and self.group: job_info.Group = self.group diff --git a/openpype/pipeline/anatomy.py b/openpype/pipeline/anatomy.py index 683960f3d8..30748206a3 100644 --- a/openpype/pipeline/anatomy.py +++ b/openpype/pipeline/anatomy.py @@ -19,6 +19,7 @@ from openpype.client import get_project from openpype.lib.path_templates import ( TemplateUnsolved, TemplateResult, + StringTemplate, TemplatesDict, FormatObject, ) @@ -606,6 +607,32 @@ class AnatomyTemplateResult(TemplateResult): return self.__class__(tmp, self.rootless) +class AnatomyStringTemplate(StringTemplate): + """String template which has access to anatomy.""" + + def __init__(self, anatomy_templates, template): + self.anatomy_templates = anatomy_templates + super(AnatomyStringTemplate, self).__init__(template) + + def format(self, data): + """Format template and add 'root' key to data if not available. + + Args: + data (dict[str, Any]): Formatting data for template. + + Returns: + AnatomyTemplateResult: Formatting result. + """ + + anatomy_templates = self.anatomy_templates + if not data.get("root"): + data = copy.deepcopy(data) + data["root"] = anatomy_templates.anatomy.roots + result = StringTemplate.format(self, data) + rootless_path = anatomy_templates.rootless_path_from_result(result) + return AnatomyTemplateResult(result, rootless_path) + + class AnatomyTemplates(TemplatesDict): inner_key_pattern = re.compile(r"(\{@.*?[^{}0]*\})") inner_key_name_pattern = re.compile(r"\{@(.*?[^{}0]*)\}") @@ -615,12 +642,6 @@ class AnatomyTemplates(TemplatesDict): self.anatomy = anatomy self.loaded_project = None - def __getitem__(self, key): - return self.templates[key] - - def get(self, key, default=None): - return self.templates.get(key, default) - def reset(self): self._raw_templates = None self._templates = None @@ -655,12 +676,7 @@ class AnatomyTemplates(TemplatesDict): def _format_value(self, value, data): if isinstance(value, RootItem): return self._solve_dict(value, data) - - result = super(AnatomyTemplates, self)._format_value(value, data) - if isinstance(result, TemplateResult): - rootless_path = self._rootless_path(result, data) - result = AnatomyTemplateResult(result, rootless_path) - return result + return super(AnatomyTemplates, self)._format_value(value, data) def set_templates(self, templates): if not templates: @@ -689,10 +705,13 @@ class AnatomyTemplates(TemplatesDict): solved_templates = self.solve_template_inner_links(templates) self._templates = solved_templates - self._objected_templates = self.create_ojected_templates( + self._objected_templates = self.create_objected_templates( solved_templates ) + def _create_template_object(self, template): + return AnatomyStringTemplate(self, template) + def default_templates(self): """Return default templates data with solved inner keys.""" return self.solve_template_inner_links( @@ -886,7 +905,8 @@ class AnatomyTemplates(TemplatesDict): return keys_by_subkey - def _dict_to_subkeys_list(self, subdict, pre_keys=None): + @classmethod + def _dict_to_subkeys_list(cls, subdict, pre_keys=None): if pre_keys is None: pre_keys = [] output = [] @@ -895,7 +915,7 @@ class AnatomyTemplates(TemplatesDict): result = list(pre_keys) result.append(key) if isinstance(value, dict): - for item in self._dict_to_subkeys_list(value, result): + for item in cls._dict_to_subkeys_list(value, result): output.append(item) else: output.append(result) @@ -908,7 +928,17 @@ class AnatomyTemplates(TemplatesDict): return {key_list[0]: value} return {key_list[0]: self._keys_to_dicts(key_list[1:], value)} - def _rootless_path(self, result, final_data): + @classmethod + def rootless_path_from_result(cls, result): + """Calculate rootless path from formatting result. + + Args: + result (TemplateResult): Result of StringTemplate formatting. + + Returns: + str: Rootless path if result contains one of anatomy roots. + """ + used_values = result.used_values missing_keys = result.missing_keys template = result.template @@ -924,7 +954,7 @@ class AnatomyTemplates(TemplatesDict): if "root" in invalid_type: return - root_keys = self._dict_to_subkeys_list({"root": used_values["root"]}) + root_keys = cls._dict_to_subkeys_list({"root": used_values["root"]}) if not root_keys: return diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 81913bcdd5..265a9c7822 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -354,6 +354,61 @@ def publish_plugins_discover(paths=None): return result +def _get_plugin_settings(host_name, project_settings, plugin, log): + """Get plugin settings based on host name and plugin name. + + Args: + host_name (str): Name of host. + project_settings (dict[str, Any]): Project settings. + plugin (pyliblish.Plugin): Plugin where settings are applied. + log (logging.Logger): Logger to log messages. + + Returns: + dict[str, Any]: Plugin settings {'attribute': 'value'}. + """ + + # Use project settings from host name category when available + try: + return ( + project_settings + [host_name] + ["publish"] + [plugin.__name__] + ) + except KeyError: + pass + + # Settings category determined from path + # - usually path is './/plugins/publish/' + # - category can be host name of addon name ('maya', 'deadline', ...) + filepath = os.path.normpath(inspect.getsourcefile(plugin)) + + split_path = filepath.rsplit(os.path.sep, 5) + if len(split_path) < 4: + log.warning( + 'plugin path too short to extract host {}'.format(filepath) + ) + return {} + + category_from_file = split_path[-4] + plugin_kind = split_path[-2] + + # TODO: change after all plugins are moved one level up + if category_from_file == "openpype": + category_from_file = "global" + + try: + return ( + project_settings + [category_from_file] + [plugin_kind] + [plugin.__name__] + ) + except KeyError: + pass + return {} + + def filter_pyblish_plugins(plugins): """Pyblish plugin filter which applies OpenPype settings. @@ -372,21 +427,21 @@ def filter_pyblish_plugins(plugins): # TODO: Don't use host from 'pyblish.api' but from defined host by us. # - kept becau on farm is probably used host 'shell' which propably # affect how settings are applied there - host = pyblish.api.current_host() + host_name = pyblish.api.current_host() project_name = os.environ.get("AVALON_PROJECT") - project_setting = get_project_settings(project_name) + project_settings = get_project_settings(project_name) system_settings = get_system_settings() # iterate over plugins for plugin in plugins[:]: + # Apply settings to plugins if hasattr(plugin, "apply_settings"): + # Use classmethod 'apply_settings' + # - can be used to target settings from custom settings place + # - skip default behavior when successful try: - # Use classmethod 'apply_settings' - # - can be used to target settings from custom settings place - # - skip default behavior when successful - plugin.apply_settings(project_setting, system_settings) - continue + plugin.apply_settings(project_settings, system_settings) except Exception: log.warning( @@ -395,53 +450,20 @@ def filter_pyblish_plugins(plugins): ).format(plugin.__name__), exc_info=True ) - - try: - config_data = ( - project_setting - [host] - ["publish"] - [plugin.__name__] + else: + # Automated + plugin_settins = _get_plugin_settings( + host_name, project_settings, plugin, log ) - except KeyError: - # host determined from path - file = os.path.normpath(inspect.getsourcefile(plugin)) - file = os.path.normpath(file) - - split_path = file.split(os.path.sep) - if len(split_path) < 4: - log.warning( - 'plugin path too short to extract host {}'.format(file) - ) - continue - - host_from_file = split_path[-4] - plugin_kind = split_path[-2] - - # TODO: change after all plugins are moved one level up - if host_from_file == "openpype": - host_from_file = "global" - - try: - config_data = ( - project_setting - [host_from_file] - [plugin_kind] - [plugin.__name__] - ) - except KeyError: - continue - - for option, value in config_data.items(): - if option == "enabled" and value is False: - log.info('removing plugin {}'.format(plugin.__name__)) - plugins.remove(plugin) - else: - log.info('setting {}:{} on plugin {}'.format( + for option, value in plugin_settins.items(): + log.info("setting {}:{} on plugin {}".format( option, value, plugin.__name__)) - setattr(plugin, option, value) + # Remove disabled plugins + if getattr(plugin, "enabled", True) is False: + plugins.remove(plugin) + def find_close_plugin(close_plugin_name, log): if close_plugin_name: diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 48171aa957..4fbb93324b 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -50,7 +50,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): project_name = context.data["projectName"] self.fill_missing_asset_docs(context, project_name) - self.fill_instance_data_from_asset(context) self.fill_latest_versions(context, project_name) self.fill_anatomy_data(context) @@ -115,23 +114,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): "Not found asset documents with names \"{}\"." ).format(joined_asset_names)) - def fill_instance_data_from_asset(self, context): - for instance in context: - asset_doc = instance.data.get("assetEntity") - if not asset_doc: - continue - - asset_data = asset_doc["data"] - for key in ( - "fps", - "frameStart", - "frameEnd", - "handleStart", - "handleEnd", - ): - if key not in instance.data and key in asset_data: - instance.data[key] = asset_data[key] - def fill_latest_versions(self, context, project_name): """Try to find latest version for each instance's subset. diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 88a9ac6f2f..a12e8d18b4 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -49,7 +49,8 @@ class ExtractBurnin(publish.Extractor): "webpublisher", "aftereffects", "photoshop", - "flame" + "flame", + "houdini" # "resolve" ] @@ -78,9 +79,10 @@ class ExtractBurnin(publish.Extractor): self.log.warning("No profiles present for create burnin") return - # QUESTION what is this for and should we raise an exception? - if "representations" not in instance.data: - raise RuntimeError("Burnin needs already created mov to work on.") + if not instance.data.get("representations"): + self.log.info( + "Instance does not have filled representations. Skipping") + return self.main_process(instance) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 6b2fd32a2a..1062683319 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -44,6 +44,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "nuke", "maya", "blender", + "houdini", "shell", "hiero", "premiere", diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 30e56300d1..4c4a7487cf 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -253,13 +253,14 @@ { "families": [], "hosts": [ - "maya" + "maya", + "houdini" ], "task_types": [], "task_names": [], "subsets": [], "burnins": { - "maya_burnin": { + "focal_length_burnin": { "TOP_LEFT": "{yy}-{mm}-{dd}", "TOP_CENTERED": "{focalLength:.2f} mm", "TOP_RIGHT": "{anatomy[version]}", diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index 100c1f5b47..3e613aa1bf 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -21,7 +21,8 @@ "floatLut": "linear", "logLut": "Cineon", "viewerLut": "sRGB", - "thumbnailLut": "sRGB" + "thumbnailLut": "sRGB", + "monitorOutLut": "sRGB" }, "regexInputs": { "inputs": [ diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 19d8667002..201dda1c2d 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1047,6 +1047,10 @@ 125, 255 ] + }, + "reference_loader": { + "namespace": "{asset_name}_{subset}_##", + "group_name": "_GRP" } }, "workfile_build": { @@ -1140,6 +1144,10 @@ } ] }, + "include_handles": { + "include_handles_default": false, + "per_task_type": [] + }, "templated_workfile_build": { "profiles": [] }, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index c249955dc8..85dee73176 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -363,6 +363,11 @@ "optional": true, "active": true }, + "ValidateBackdrop": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateScript": { "enabled": true, "optional": true, @@ -537,45 +542,10 @@ "create_first_version": false, "custom_templates": [], "builder_on_start": false, - "profiles": [ - { - "task_types": [], - "tasks": [], - "current_context": [ - { - "subset_name_filters": [], - "families": [ - "render", - "plate" - ], - "repre_names": [ - "exr", - "dpx", - "mov", - "mp4", - "h264" - ], - "loaders": [ - "LoadClip" - ] - } - ], - "linked_assets": [] - } - ] + "profiles": [] }, "templated_workfile_build": { - "profiles": [ - { - "task_types": [ - "Compositing" - ], - "task_names": [], - "path": "{project[name]}/templates/comp.nk", - "keep_placeholder": true, - "create_first_version": true - } - ] + "profiles": [] }, "filters": {} } diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 89f12afd9b..adc600bccb 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -448,6 +448,8 @@ class TextEntity(InputEntity): self.multiline = self.schema_data.get("multiline", False) self.placeholder_text = self.schema_data.get("placeholder") self.value_hints = self.schema_data.get("value_hints") or [] + self.minimum_lines_count = ( + self.schema_data.get("minimum_lines_count") or 0) def schema_validations(self): if self.multiline and self.value_hints: diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index cff614a4bb..c333628b25 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -380,6 +380,7 @@ How output of the schema could look like on save: - simple text input - key `"multiline"` allows to enter multiple lines of text (Default: `False`) - key `"placeholder"` allows to show text inside input when is empty (Default: `None`) + - key `"minimum_lines_count"` allows to define minimum size hint for UI. Can be 0-n lines. ``` { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json index f44f92438c..ea05f4ab9b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json @@ -42,10 +42,19 @@ "nuke-default": "nuke-default" }, { - "aces_1.0.3": "aces_1.0.3" + "aces_1.0.3": "aces_1.0.3 (12)" }, { - "aces_1.1": "aces_1.1" + "aces_1.1": "aces_1.1 (12, 13)" + }, + { + "aces_1.2": "aces_1.2 (13, 14)" + }, + { + "studio-config-v1.0.0_aces-v1.3_ocio-v2.1": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" + }, + { + "cg-config-v1.0.0_aces-v1.3_ocio-v2.1": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" }, { "custom": "custom" @@ -93,6 +102,11 @@ "type": "text", "key": "thumbnailLut", "label": "Thumbnails" + }, + { + "type": "text", + "key": "monitorOutLut", + "label": "Monitor" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index 47dfb37024..ccc967a260 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -151,6 +151,40 @@ } ] }, + { + "type": "dict", + "key": "include_handles", + "collapsible": true, + "label": "Include/Exclude Handles in default playback & render range", + "children": [ + { + "key": "include_handles_default", + "label": "Include handles by default", + "type": "boolean" + }, + { + "type": "list", + "key": "per_task_type", + "label": "Include/exclude handles by task type", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "task-types-enum", + "key": "task_type", + "label": "Task types" + }, + { + "type": "boolean", + "key": "include_handles", + "label": "Include handles" + } + ] + } + } + ] + }, { "type": "schema", "name": "schema_scriptsmenu" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 6b2315abc0..c1895c4824 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -91,6 +91,28 @@ "key": "yetiRig" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "reference_loader", + "label": "Reference Loader", + "children": [ + { + "type": "text", + "label": "Namespace", + "key": "namespace" + }, + { + "type": "text", + "label": "Group name", + "key": "group_name" + }, + { + "type": "label", + "label": "Here's a link to the doc where you can find explanations about customing the naming of referenced assets: https://openpype.io/docs/admin_hosts_maya#load-plugins" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json index 1cd6f0e67f..21f6baff9e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -74,28 +74,34 @@ "nuke-default": "nuke-default" }, { - "spi-vfx": "spi-vfx" + "spi-vfx": "spi-vfx (11)" }, { - "spi-anim": "spi-anim" + "spi-anim": "spi-anim (11)" }, { - "aces_0.1.1": "aces_0.1.1" + "aces_0.1.1": "aces_0.1.1 (11)" }, { - "aces_0.7.1": "aces_0.7.1" + "aces_0.7.1": "aces_0.7.1 (11)" }, { - "aces_1.0.1": "aces_1.0.1" + "aces_1.0.1": "aces_1.0.1 (11)" }, { - "aces_1.0.3": "aces_1.0.3" + "aces_1.0.3": "aces_1.0.3 (11, 12)" }, { - "aces_1.1": "aces_1.1" + "aces_1.1": "aces_1.1 (12, 13)" }, { - "aces_1.2": "aces_1.2" + "aces_1.2": "aces_1.2 (13, 14)" + }, + { + "studio-config-v1.0.0_aces-v1.3_ocio-v2.1": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" + }, + { + "cg-config-v1.0.0_aces-v1.3_ocio-v2.1": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" }, { "custom": "custom" @@ -257,4 +263,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 1c542279fc..ce9fa04c6a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -62,7 +62,7 @@ "template_data": [ { "key": "ValidateCorrectAssetName", - "label": "Validate Correct Asset name" + "label": "Validate Correct Asset Name" } ] }, @@ -72,7 +72,7 @@ "template_data": [ { "key": "ValidateContainers", - "label": "ValidateContainers" + "label": "Validate Containers" } ] }, @@ -81,7 +81,7 @@ "collapsible": true, "checkbox_key": "enabled", "key": "ValidateKnobs", - "label": "ValidateKnobs", + "label": "Validate Knobs", "is_group": true, "children": [ { @@ -104,6 +104,10 @@ "key": "ValidateOutputResolution", "label": "Validate Output Resolution" }, + { + "key": "ValidateBackdrop", + "label": "Validate Backdrop" + }, { "key": "ValidateGizmo", "label": "Validate Gizmo (Group)" diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index b62ae7ecc1..7754e4aa02 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -6,6 +6,7 @@ import collections import uuid import tempfile import shutil +import inspect from abc import ABCMeta, abstractmethod import six @@ -26,8 +27,8 @@ from openpype.pipeline import ( PublishValidationError, KnownPublishError, registered_host, - legacy_io, get_process_id, + OptionalPyblishPluginMixin, ) from openpype.pipeline.create import ( CreateContext, @@ -2307,6 +2308,37 @@ class PublisherController(BasePublisherController): def _process_main_thread_item(self, item): item() + def _is_publish_plugin_active(self, plugin): + """Decide if publish plugin is active. + + This is hack because 'active' is mis-used in mixin + 'OptionalPyblishPluginMixin' where 'active' is used for default value + of optional plugins. Because of that is 'active' state of plugin + which inherit from 'OptionalPyblishPluginMixin' ignored. That affects + headless publishing inside host, potentially remote publishing. + + We have to change that to match pyblish base, but we can do that + only when all hosts use Publisher because the change requires + change of settings schemas. + + Args: + plugin (pyblish.Plugin): Plugin which should be checked if is + active. + + Returns: + bool: Is plugin active. + """ + + if plugin.active: + return True + + if not plugin.optional: + return False + + if OptionalPyblishPluginMixin in inspect.getmro(plugin): + return True + return False + def _publish_iterator(self): """Main logic center of publishing. @@ -2315,11 +2347,9 @@ class PublisherController(BasePublisherController): states of currently processed publish plugin and instance. Also change state of processed orders like validation order has passed etc. - Also stops publishing if should stop on validation. - - QUESTION: - Does validate button still make sense? + Also stops publishing, if should stop on validation. """ + for idx, plugin in enumerate(self._publish_plugins): self._publish_progress = idx @@ -2344,6 +2374,11 @@ class PublisherController(BasePublisherController): # Add plugin to publish report self._publish_report.add_plugin_iter(plugin, self._publish_context) + # WARNING This is hack fix for optional plugins + if not self._is_publish_plugin_active(plugin): + self._publish_report.set_plugin_skipped() + continue + # Trigger callback that new plugin is going to be processed plugin_label = plugin.__name__ if hasattr(plugin, "label") and plugin.label: @@ -2450,7 +2485,11 @@ def collect_families_from_instances(instances, only_active=False): instances(list): List of publish instances from which are families collected. only_active(bool): Return families only for active instances. + + Returns: + list[str]: Families available on instances. """ + all_families = set() for instance in instances: if only_active: diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py index 37da4ab3f2..ff10e091b8 100644 --- a/openpype/tools/publisher/publish_report_viewer/model.py +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -162,7 +162,8 @@ class PluginsModel(QtGui.QStandardItemModel): items = [] for plugin_item in plugin_items: - item = QtGui.QStandardItem(plugin_item.label) + label = plugin_item.label or plugin_item.name + item = QtGui.QStandardItem(label) item.setData(False, ITEM_IS_GROUP_ROLE) item.setData(plugin_item.label, ITEM_LABEL_ROLE) item.setData(plugin_item.id, ITEM_ID_ROLE) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index d51f9b9684..117eca7d6b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -360,14 +360,16 @@ class TextWidget(InputWidget): def _add_inputs_to_layout(self): multiline = self.entity.multiline if multiline: - self.input_field = SettingsPlainTextEdit(self.content_widget) + input_field = SettingsPlainTextEdit(self.content_widget) + if self.entity.minimum_lines_count: + input_field.set_minimum_lines(self.entity.minimum_lines_count) else: - self.input_field = SettingsLineEdit(self.content_widget) - + input_field = SettingsLineEdit(self.content_widget) placeholder_text = self.entity.placeholder_text if placeholder_text: - self.input_field.setPlaceholderText(placeholder_text) + input_field.setPlaceholderText(placeholder_text) + self.input_field = input_field self.setFocusProxy(self.input_field) layout_kwargs = {} diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index fd04cb0a23..092b6f0e51 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -4,7 +4,6 @@ from qtpy import QtWidgets, QtCore, QtGui import qtawesome from openpype.client import get_projects -from openpype.pipeline import AvalonMongoDB from openpype.style import get_objected_colors from openpype.tools.utils.widgets import ImageButton from openpype.tools.utils.lib import paint_image_with_color @@ -97,6 +96,7 @@ class CompleterView(QtWidgets.QListView): # Open the widget unactivated self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating) + self.setAttribute(QtCore.Qt.WA_NoMouseReplay) delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(delegate) @@ -241,6 +241,18 @@ class SettingsLineEdit(PlaceholderLineEdit): if self._completer is not None: self._completer.set_text_filter(text) + def _completer_should_be_visible(self): + return ( + self.isVisible() + and (self.hasFocus() or self._completer.hasFocus()) + ) + + def _show_completer(self): + if self._completer_should_be_visible(): + self._focus_timer.start() + self._completer.show() + self._update_completer() + def _update_completer(self): if self._completer is None or not self._completer.isVisible(): return @@ -249,7 +261,7 @@ class SettingsLineEdit(PlaceholderLineEdit): self._completer.move(new_point) def _on_focus_timer(self): - if not self.hasFocus() and not self._completer.hasFocus(): + if not self._completer_should_be_visible(): self._completer.hide() self._focus_timer.stop() @@ -258,9 +270,7 @@ class SettingsLineEdit(PlaceholderLineEdit): self.focused_in.emit() if self._completer is not None: - self._focus_timer.start() - self._completer.show() - self._update_completer() + self._show_completer() def paintEvent(self, event): super(SettingsLineEdit, self).paintEvent(event) @@ -300,11 +310,32 @@ class SettingsLineEdit(PlaceholderLineEdit): class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): focused_in = QtCore.Signal() + _min_lines = 0 def focusInEvent(self, event): super(SettingsPlainTextEdit, self).focusInEvent(event) self.focused_in.emit() + def set_minimum_lines(self, lines): + self._min_lines = lines + self.update() + + def minimumSizeHint(self): + result = super(SettingsPlainTextEdit, self).minimumSizeHint() + if self._min_lines < 1: + return result + document = self.document() + margins = self.contentsMargins() + d_margin = ( + ((document.documentMargin() + self.frameWidth()) * 2) + + margins.top() + margins.bottom() + ) + font = document.defaultFont() + font_metrics = QtGui.QFontMetrics(font) + result.setHeight( + d_margin + (font_metrics.lineSpacing() * self._min_lines)) + return result + class SettingsToolBtn(ImageButton): _mask_pixmap = None diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 5c8c48b2e3..eea6456c76 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -106,6 +106,37 @@ or Deadlines **Draft Tile Assembler**. This is useful to fix some specific renderer glitches and advanced hacking of Maya Scene files. `Patch name` is label for patch for easier orientation. `Patch regex` is regex used to find line in file, after `Patch line` string is inserted. Note that you need to add line ending. +## Load Plugins + +### Reference Loader + +#### Namespace and Group Name +Here you can create your own custom naming for the reference loader. + +The custom naming is split into two parts: namespace and group name. If you don't set the namespace or the group name, an error will occur. +Here's the different variables you can use: + +
+
+ +| Token | Description | +|---|---| +|`{asset_name}` | Asset name | +|`{asset_type}` | Asset type | +|`{subset}` | Subset name | +|`{family}` | Subset family | + +
+
+ +The namespace field can contain a single group of '#' number tokens to indicate where the namespace's unique index should go. The amount of tokens defines the zero padding of the number, e.g ### turns into 001. + +Warning: Note that a namespace will always be prefixed with a _ if it starts with a digit. + +Example: + +![Namespace and Group Name](assets/maya-admin_custom_namespace.png) + ### Extract GPU Cache ![Maya GPU Cache](assets/maya-admin_gpu_cache.png) @@ -169,6 +200,17 @@ Most settings to override in the viewport are self explanatory and can be found These options are set on the camera shape when publishing the review. They correspond to attributes on the Maya camera shape node. ![Extract Playblast Settings](assets/maya-admin_extract_playblast_settings_camera_options.png) +## Include/exclude handles by task type +You can include or exclude handles, globally or by task type. + +The "Include handles by default" defines whether by default handles are included. Additionally you can add a per task type override whether you want to include or exclude handles. + +For example, in this image you can see that handles are included by default in all task types, except for the 'Lighting' task, where the toggle is disabled. +![Include/exclude handles](assets/maya-admin_exclude_handles.png) + +And here you can see that the handles are disabled by default, except in 'Animation' task where it's enabled. +![Custom menu definition](assets/maya-admin_include_handles.png) + ## Custom Menu You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. diff --git a/website/docs/artist_concepts.md b/website/docs/artist_concepts.md index 7582540811..1e55c8139d 100644 --- a/website/docs/artist_concepts.md +++ b/website/docs/artist_concepts.md @@ -14,17 +14,29 @@ OpenPype has a limitation regarding duplicated names. Name of assets must be uni ### Subset -Usually, an asset needs to be created in multiple *'flavours'*. A character might have multiple different looks, model needs to be published in different resolutions, a standard animation rig might not be usable in a crowd system and so on. 'Subsets' are here to accommodate all this variety that might be needed within a single asset. A model might have subset: *'main'*, *'proxy'*, *'sculpt'*, while data of *'look'* family could have subsets *'main'*, *'dirty'*, *'damaged'*. Subsets have some recommendations for their names, but ultimately it's up to the artist to use them for separation of publishes when needed. +A published output from an asset results in a subset. + +The subset type is referred to as [family](#family), for example a rig, pointcache, look. +A single asset can have many subsets, even of a single family, named [variants](#variant). +By default a subset is named as a combination of family + variant, sometimes prefixed with the task name (like workfile). + +### Variant + +Usually, an asset needs to be created in multiple *'flavours'*. A character might have multiple different looks, model needs to be published in different resolutions, a standard animation rig might not be usable in a crowd system and so on. 'Variants' are here to accommodate all this variety that might be needed within a single asset. A model might have variant: *'main'*, *'proxy'*, *'sculpt'*, while data of *'look'* family could have subsets *'main'*, *'dirty'*, *'damaged'*. Variants have some recommendations for their names, but ultimately it's up to the artist to use them for separation of publishes when needed. ### Version -A numbered iteration of a given subset. Each version contains at least one [representation][daa74ebf]. +A numbered iteration of a given subset. Each version contains at least one [representation](#representation). - [daa74ebf]: #representation "representation" +#### Hero version + +A hero version is a version that is always the latest published version. When a new publish is generated its written over the previous hero version replacing it in-place as opposed to regular versions where each new publish is a higher version number. + +This is an optional feature. The generation of hero versions can be completely disabled in OpenPype by an admin through the Studio Settings. ### Representation -Each published variant can come out of the software in multiple representations. All of them hold exactly the same data, but in different formats. A model, for example, might be saved as `.OBJ`, Alembic, Maya geometry or as all of them, to be ready for pickup in any other applications supporting these formats. +Each published subset version can come out of the software in multiple representations. All of them hold exactly the same data, but in different formats. A model, for example, might be saved as `.OBJ`, Alembic, Maya geometry or as all of them, to be ready for pickup in any other applications supporting these formats. #### Naming convention @@ -33,18 +45,22 @@ At this moment names of assets, tasks, subsets or representations can contain on ### Family -Each published [subset][3b89d8e0] can have exactly one family assigned to it. Family determines the type of data that the subset holds. Family doesn't dictate the file type, but can enforce certain technical specifications. For example OpenPype default configuration expects `model` family to only contain geometry without any shaders or joints when it is published. +Each published [subset](#subset) can have exactly one family assigned to it. Family determines the type of data that the subset holds. Family doesn't dictate the file type, but can enforce certain technical specifications. For example OpenPype default configuration expects `model` family to only contain geometry without any shaders or joints when it is published. +### Task - [3b89d8e0]: #subset "subset" +A task defines a work area for an asset where an artist can work in. For example asset *characterA* can have tasks named *modeling* and *rigging*. Tasks also have types. Multiple tasks of the same type may exist on an asset. A task with type `fx` could for example appear twice as *fx_fire* and *fx_cloth*. +Without a task you cannot launch a host application. +### Workfile + +The source scene file an artist works in within their task. These are versioned scene files and can be loaded and saved (automatically named) through the [workfiles tool](artist_tools_workfiles.md). ### Host General term for Software or Application supported by OpenPype and Avalon. These are usually DCC applications like Maya, Houdini or Nuke, but can also be a web based service like Ftrack or Clockify. - ### Tool Small piece of software usually dedicated to a particular purpose. Most of OpenPype and Avalon tools have GUI, but some are command line only. @@ -54,6 +70,10 @@ Small piece of software usually dedicated to a particular purpose. Most of OpenP Process of exporting data from your work scene to versioned, immutable file that can be used by other artists in the studio. +#### (Publish) Instance + +A publish instance is a single entry which defines a publish output. Publish instances persist within the workfile. This way we can expect that a publish from a newer workfile will produce similar consistent versioned outputs. + ### Load Process of importing previously published subsets into your current scene, using any of the OpenPype tools. diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index f2b128ffc6..8874a0b5cf 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -14,20 +14,29 @@ sidebar_label: Houdini - [Library Loader](artist_tools_library-loader) ## Publishing Alembic Cameras -You can publish baked camera in Alembic format. Select your camera and go **OpenPype -> Create** and select **Camera (abc)**. +You can publish baked camera in Alembic format. + +Select your camera and go **OpenPype -> Create** and select **Camera (abc)**. This will create Alembic ROP in **out** with path and frame range already set. This node will have a name you've assigned in the **Creator** menu. For example if you name the subset `Default`, output Alembic Driver will be named `cameraDefault`. After that, you can **OpenPype -> Publish** and after some validations your camera will be published to `abc` file. ## Publishing Composites - Image Sequences -You can publish image sequence directly from Houdini. You can use any `cop` network you have and publish image -sequence generated from it. For example I've created simple **cop** graph to generate some noise: +You can publish image sequences directly from Houdini's image COP networks. + +You can use any COP node and publish the image sequence generated from it. For example this simple graph to generate some noise: + ![Noise COP](assets/houdini_imagesequence_cop.png) -If I want to publish it, I'll select node I like - in this case `radialblur1` and go **OpenPype -> Create** and -select **Composite (Image Sequence)**. This will create `/out/imagesequenceNoise` Composite ROP (I've named my subset -*Noise*) with frame range set. When you hit **Publish** it will render image sequence from selected node. +To publish the output of the `radialblur1` go to **OpenPype -> Create** and +select **Composite (Image Sequence)**. If you name the variant *Noise* this will create the `/out/imagesequenceNoise` Composite ROP with the frame range set. + +When you hit **Publish** it will render image sequence from selected node. + +:::info Use selection +With *Use selection* is enabled on create the node you have selected when creating will be the node used for published. (It set the Composite ROP node's COP path to it). If you don't do this you'll have to manually set the path as needed on e.g. `/out/imagesequenceNoise` to ensure it outputs what you want. +::: ## Publishing Point Caches (alembic) Publishing point caches in alembic format is pretty straightforward, but it is by default enforcing better compatibility @@ -46,6 +55,16 @@ you handle `path` attribute is up to you, this is just an example.* Now select the `output0` node and go **OpenPype -> Create** and select **Point Cache**. It will create Alembic ROP `/out/pointcacheStrange` +## Publishing Reviews (OpenGL) +To generate a review output from Houdini you need to create a **review** instance. +Go to **OpenPype -> Create** and select **Review**. + +![Houdini Create Review](assets/houdini_review_create_attrs.png) + +On create, with the **Use Selection** checkbox enabled it will set up the first +camera found in your selection as the camera for the OpenGL ROP node and other +non-cameras are set in **Force Objects**. It will then render those even if +their display flag is disabled in your scene. ## Redshift :::note Work in progress diff --git a/website/docs/assets/houdini_review_create_attrs.png b/website/docs/assets/houdini_review_create_attrs.png new file mode 100644 index 0000000000..8735e79914 Binary files /dev/null and b/website/docs/assets/houdini_review_create_attrs.png differ diff --git a/website/docs/assets/maya-admin_custom_namespace.png b/website/docs/assets/maya-admin_custom_namespace.png new file mode 100644 index 0000000000..80707ea727 Binary files /dev/null and b/website/docs/assets/maya-admin_custom_namespace.png differ diff --git a/website/docs/assets/maya-admin_exclude_handles.png b/website/docs/assets/maya-admin_exclude_handles.png new file mode 100644 index 0000000000..9a50f2c287 Binary files /dev/null and b/website/docs/assets/maya-admin_exclude_handles.png differ diff --git a/website/docs/assets/maya-admin_include_handles.png b/website/docs/assets/maya-admin_include_handles.png new file mode 100644 index 0000000000..88d2270ddc Binary files /dev/null and b/website/docs/assets/maya-admin_include_handles.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 2de9038f3f..c17f707830 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -255,7 +255,7 @@ suffix is **"client"** then the final suffix is **"h264_client"**. | resolution_height | Resolution height. | | fps | Fps of an output. | | timecode | Timecode by frame start and fps. | - | focalLength | **Only available in Maya**

Camera focal length per frame. Use syntax `{focalLength:.2f}` for decimal truncating. Eg. `35.234985` with `{focalLength:.2f}` would produce `35.23`, whereas `{focalLength:.0f}` would produce `35`. | + | focalLength | **Only available in Maya and Houdini**

Camera focal length per frame. Use syntax `{focalLength:.2f}` for decimal truncating. Eg. `35.234985` with `{focalLength:.2f}` would produce `35.23`, whereas `{focalLength:.0f}` would produce `35`. | :::warning `timecode` is a specific key that can be **only at the end of content**. (`"BOTTOM_RIGHT": "TC: {timecode}"`) diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index 44c2a28dec..6a057f4bb4 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -304,7 +304,7 @@ If source representation has suffix **"h264"** and burnin suffix is **"client"** | resolution_height | Resolution height. | | fps | Fps of an output. | | timecode | Timecode by frame start and fps. | - | focalLength | **Only available in Maya**

Camera focal length per frame. Use syntax `{focalLength:.2f}` for decimal truncating. Eg. `35.234985` with `{focalLength:.2f}` would produce `35.23`, whereas `{focalLength:.0f}` would produce `35`. | + | focalLength | **Only available in Maya and Houdini**

Camera focal length per frame. Use syntax `{focalLength:.2f}` for decimal truncating. Eg. `35.234985` with `{focalLength:.2f}` would produce `35.23`, whereas `{focalLength:.0f}` would produce `35`. | :::warning `timecode` is specific key that can be **only at the end of content**. (`"BOTTOM_RIGHT": "TC: {timecode}"`)