Merge branch 'develop' into feature/new_publisher_core

This commit is contained in:
iLLiCiTiT 2021-06-30 17:21:30 +02:00
commit ac9575d722
108 changed files with 2886 additions and 1065 deletions

View file

@ -43,10 +43,10 @@ jobs:
uses: heinrichreimer/github-changelog-generator-action@v2.2
with:
token: ${{ secrets.ADMIN_TOKEN }}
breakingLabel: '#### 💥 Breaking'
enhancementLabel: '#### 🚀 Enhancements'
bugsLabel: '#### 🐛 Bug fixes'
deprecatedLabel: '#### ⚠️ Deprecations'
breakingLabel: '**💥 Breaking**'
enhancementLabel: '**🚀 Enhancements**'
bugsLabel: '**🐛 Bug fixes**'
deprecatedLabel: '**⚠️ Deprecations**'
addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}'
issues: false
issuesWoLabels: false

View file

@ -1,9 +1,9 @@
name: Stable Release
on:
push:
tags:
- '*[0-9].*[0-9].*[0-9]*'
release:
types:
- prereleased
jobs:
create_release:
@ -23,35 +23,26 @@ jobs:
- name: Install Python requirements
run: pip install gitpython semver
- name: Set env
run: |
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
git config user.email ${{ secrets.CI_EMAIL }}
git config user.name ${{ secrets.CI_USER }}
git fetch
git checkout -b main origin/main
git tag -d ${GITHUB_REF#refs/*/}
git remote set-url --push origin https://pypebot:${{ secrets.ADMIN_TOKEN }}@github.com/pypeclub/openpype
git push origin --delete ${GITHUB_REF#refs/*/}
echo PREVIOUS_VERSION=`git describe --tags --match="[0-9]*" --abbrev=0` >> $GITHUB_ENV
- name: 💉 Inject new version into files
id: version
if: steps.version_type.outputs.type != 'skip'
run: |
python ./tools/ci_tools.py --version ${{ env.RELEASE_VERSION }}
echo ::set-output name=current_version::${GITHUB_REF#refs/*/}
RESULT=$(python ./tools/ci_tools.py --finalize ${GITHUB_REF#refs/*/})
LASTRELEASE=$(python ./tools/ci_tools.py --lastversion release)
echo ::set-output name=last_release::$LASTRELEASE
echo ::set-output name=release_tag::$RESULT
- name: "✏️ Generate full changelog"
if: steps.version_type.outputs.type != 'skip'
if: steps.version.outputs.release_tag != 'skip'
id: generate-full-changelog
uses: heinrichreimer/github-changelog-generator-action@v2.2
with:
token: ${{ secrets.ADMIN_TOKEN }}
breakingLabel: '#### 💥 Breaking'
enhancementLabel: '#### 🚀 Enhancements'
bugsLabel: '#### 🐛 Bug fixes'
deprecatedLabel: '#### ⚠️ Deprecations'
breakingLabel: '**💥 Breaking**'
enhancementLabel: '**🚀 Enhancements**'
bugsLabel: '**🐛 Bug fixes**'
deprecatedLabel: '**⚠️ Deprecations**'
addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}'
issues: false
issuesWoLabels: false
@ -64,39 +55,76 @@ jobs:
compareLink: true
stripGeneratorNotice: true
verbose: true
futureRelease: ${{ env.RELEASE_VERSION }}
futureRelease: ${{ steps.version.outputs.release_tag }}
excludeTagsRegex: "CI/.+"
releaseBranch: "main"
- name: "🖨️ Print changelog to console"
run: echo ${{ steps.generate-last-changelog.outputs.changelog }}
- name: 💾 Commit and Tag
id: git_commit
if: steps.version_type.outputs.type != 'skip'
if: steps.version.outputs.release_tag != 'skip'
run: |
git config user.email ${{ secrets.CI_EMAIL }}
git config user.name ${{ secrets.CI_USER }}
git add .
git commit -m "[Automated] Release"
tag_name="${{ env.RELEASE_VERSION }}"
git push
git tag -fa $tag_name -m "stable release"
git remote set-url --push origin https://pypebot:${{ secrets.ADMIN_TOKEN }}@github.com/pypeclub/openpype
git push origin $tag_name
tag_name="${{ steps.version.outputs.release_tag }}"
git tag -a $tag_name -m "stable release"
- name: "🚀 Github Release"
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN }}
DRAFT_RELEASE: "false"
PRE_RELEASE: "false"
CHANGELOG_FILE: "CHANGELOG.md"
ALLOW_EMPTY_CHANGELOG: "false"
ALLOW_TAG_PREFIX: "true"
- name: 🔏 Push to protected main branch
if: steps.version.outputs.release_tag != 'skip'
uses: CasperWA/push-protected@v2
with:
token: ${{ secrets.ADMIN_TOKEN }}
branch: main
tags: true
unprotect_reviews: true
- name: "✏️ Generate last changelog"
if: steps.version.outputs.release_tag != 'skip'
id: generate-last-changelog
uses: heinrichreimer/github-changelog-generator-action@v2.2
with:
token: ${{ secrets.ADMIN_TOKEN }}
breakingLabel: '**💥 Breaking**'
enhancementLabel: '**🚀 Enhancements**'
bugsLabel: '**🐛 Bug fixes**'
deprecatedLabel: '**⚠️ Deprecations**'
addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}'
issues: false
issuesWoLabels: false
sinceTag: ${{ steps.version.outputs.last_release }}
maxIssues: 100
pullRequests: true
prWoLabels: false
author: false
unreleased: true
compareLink: true
stripGeneratorNotice: true
verbose: true
futureRelease: ${{ steps.version.outputs.release_tag }}
excludeTagsRegex: "CI/.+"
releaseBranch: "main"
stripHeaders: true
base: 'none'
- name: 🔨 Merge main back to develop
- name: 🚀 Github Release
if: steps.version.outputs.release_tag != 'skip'
uses: ncipollo/release-action@v1
with:
body: ${{ steps.generate-last-changelog.outputs.changelog }}
tag: ${{ steps.version.outputs.release_tag }}
token: ${{ secrets.ADMIN_TOKEN }}
- name: ☠ Delete Pre-release
if: steps.version.outputs.release_tag != 'skip'
uses: cb80/delrel@latest
with:
tag: "${{ steps.version.outputs.current_version }}"
- name: 🔁 Merge main back to develop
if: steps.version.outputs.release_tag != 'skip'
uses: everlytic/branch-merge@1.1.0
if: steps.version_type.outputs.type != 'skip'
with:
github_token: ${{ secrets.ADMIN_TOKEN }}
source_ref: 'main'

View file

@ -1,11 +1,96 @@
# Changelog
## [3.1.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.2.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.4...HEAD)
#### 🚀 Enhancements
**🚀 Enhancements**
- Settings UI copy/paste [\#1769](https://github.com/pypeclub/OpenPype/pull/1769)
- Workfile tool widths [\#1766](https://github.com/pypeclub/OpenPype/pull/1766)
- Push hierarchical attributes care about task parent changes [\#1763](https://github.com/pypeclub/OpenPype/pull/1763)
- Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757)
- Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739)
- Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736)
- PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726)
- Autoupdate launcher [\#1725](https://github.com/pypeclub/OpenPype/pull/1725)
- Subset template and TVPaint subset template docs [\#1717](https://github.com/pypeclub/OpenPype/pull/1717)
- Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708)
- Overscan color extract review [\#1701](https://github.com/pypeclub/OpenPype/pull/1701)
- Nuke: Prerender Frame Range by default [\#1699](https://github.com/pypeclub/OpenPype/pull/1699)
- Smoother edges of color triangle [\#1695](https://github.com/pypeclub/OpenPype/pull/1695)
**🐛 Bug fixes**
- FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775)
- Project specific environments [\#1767](https://github.com/pypeclub/OpenPype/pull/1767)
- Settings UI with refresh button [\#1764](https://github.com/pypeclub/OpenPype/pull/1764)
- Standalone publisher thumbnail extractor fix [\#1761](https://github.com/pypeclub/OpenPype/pull/1761)
- Anatomy others templates don't cause crash [\#1758](https://github.com/pypeclub/OpenPype/pull/1758)
- Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745)
- hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743)
- Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742)
- Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741)
- Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737)
- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735)
- Ftrack missing custom attribute message [\#1734](https://github.com/pypeclub/OpenPype/pull/1734)
- Launcher project changes [\#1733](https://github.com/pypeclub/OpenPype/pull/1733)
- Ftrack sync status [\#1732](https://github.com/pypeclub/OpenPype/pull/1732)
- TVPaint use layer name for default variant [\#1724](https://github.com/pypeclub/OpenPype/pull/1724)
- Default subset template for TVPaint review and workfile families [\#1716](https://github.com/pypeclub/OpenPype/pull/1716)
- Maya: Extract review hotfix [\#1714](https://github.com/pypeclub/OpenPype/pull/1714)
- Settings: Imageio improving granularity [\#1711](https://github.com/pypeclub/OpenPype/pull/1711)
- Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679)
**Merged pull requests:**
- Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773)
- TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755)
- global: removing obsolete ftrack validator plugin [\#1710](https://github.com/pypeclub/OpenPype/pull/1710)
## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4)
**Merged pull requests:**
- celaction fixes [\#1754](https://github.com/pypeclub/OpenPype/pull/1754)
- celaciton: audio subset changed data structure [\#1750](https://github.com/pypeclub/OpenPype/pull/1750)
## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3)
**🐛 Bug fixes**
- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727)
## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2)
**🚀 Enhancements**
- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650)
**🐛 Bug fixes**
- Maya: Extract review hotfix - 2.x backport [\#1713](https://github.com/pypeclub/OpenPype/pull/1713)
- StandalonePublisher: instance data attribute `keepSequence` [\#1668](https://github.com/pypeclub/OpenPype/pull/1668)
**Merged pull requests:**
- 1698 Nuke: Prerender Frame Range by default [\#1709](https://github.com/pypeclub/OpenPype/pull/1709)
## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.1.0-nightly.4...3.1.0)
**🚀 Enhancements**
- Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703)
- Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702)
- OpenPype style in modules [\#1694](https://github.com/pypeclub/OpenPype/pull/1694)
- Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689)
- \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683)
- Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682)
@ -13,26 +98,30 @@
- Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667)
- TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663)
- TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662)
- Editorial: conform assets validator [\#1659](https://github.com/pypeclub/OpenPype/pull/1659)
- Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657)
- Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653)
- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650)
- \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649)
#### 🐛 Bug fixes
**🐛 Bug fixes**
- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707)
- Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705)
- Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691)
- Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687)
- Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675)
- Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671)
- Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660)
- Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652)
- Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648)
- Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646)
- Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645)
- New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644)
**Merged pull requests:**
- update dependencies [\#1697](https://github.com/pypeclub/OpenPype/pull/1697)
- Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686)
- Add docstrings to Project manager tool [\#1556](https://github.com/pypeclub/OpenPype/pull/1556)
# Changelog

View file

@ -1,3 +1,4 @@
from copy import deepcopy
import openpype.hosts.hiero.api as phiero
# from openpype.hosts.hiero.api import plugin, lib
# reload(lib)
@ -206,20 +207,24 @@ class CreateShotClip(phiero.Creator):
presets = None
def process(self):
# Creator copy of object attributes that are modified during `process`
presets = deepcopy(self.presets)
gui_inputs = deepcopy(self.gui_inputs)
# get key pares from presets and match it on ui inputs
for k, v in self.gui_inputs.items():
for k, v in gui_inputs.items():
if v["type"] in ("dict", "section"):
# nested dictionary (only one level allowed
# for sections and dict)
for _k, _v in v["value"].items():
if self.presets.get(_k):
self.gui_inputs[k][
"value"][_k]["value"] = self.presets[_k]
if self.presets.get(k):
self.gui_inputs[k]["value"] = self.presets[k]
if presets.get(_k):
gui_inputs[k][
"value"][_k]["value"] = presets[_k]
if presets.get(k):
gui_inputs[k]["value"] = presets[k]
# open widget for plugins inputs
widget = self.widget(self.gui_name, self.gui_info, self.gui_inputs)
widget = self.widget(self.gui_name, self.gui_info, gui_inputs)
widget.exec_()
if len(self.selected) < 1:

View file

@ -46,12 +46,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
source_clip = track_item.source()
self.log.debug("clip_name: {}".format(clip_name))
# get clips subtracks and anotations
annotations = self.clip_annotations(source_clip)
subtracks = self.clip_subtrack(track_item)
self.log.debug("Annotations: {}".format(annotations))
self.log.debug(">> Subtracks: {}".format(subtracks))
# get openpype tag data
tag_data = phiero.get_track_item_pype_data(track_item)
self.log.debug("__ tag_data: {}".format(pformat(tag_data)))
@ -62,6 +56,12 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
if tag_data.get("id") != "pyblish.avalon.instance":
continue
# get clips subtracks and anotations
annotations = self.clip_annotations(source_clip)
subtracks = self.clip_subtrack(track_item)
self.log.debug("Annotations: {}".format(annotations))
self.log.debug(">> Subtracks: {}".format(subtracks))
# solve handles length
tag_data["handleStart"] = min(
tag_data["handleStart"], int(track_item.handleInLength()))

View file

@ -72,7 +72,7 @@ class ExtractPlayblast(openpype.api.Extractor):
# Isolate view is requested by having objects in the set besides a
# camera.
if preset.pop("isolate_view", False) or instance.data.get("isolate"):
if preset.pop("isolate_view", False) and instance.data.get("isolate"):
preset["isolate"] = instance.data["setMembers"]
# Show/Hide image planes on request.

View file

@ -75,7 +75,7 @@ class ExtractThumbnail(openpype.api.Extractor):
# Isolate view is requested by having objects in the set besides a
# camera.
if preset.pop("isolate_view", False) or instance.data.get("isolate"):
if preset.pop("isolate_view", False) and instance.data.get("isolate"):
preset["isolate"] = instance.data["setMembers"]
with maintained_time():

View file

@ -286,7 +286,8 @@ def add_button_write_to_read(node):
node.addKnob(knob)
def create_write_node(name, data, input=None, prenodes=None, review=True):
def create_write_node(name, data, input=None, prenodes=None,
review=True, linked_knobs=None):
''' Creating write node which is group node
Arguments:
@ -465,12 +466,16 @@ def create_write_node(name, data, input=None, prenodes=None, review=True):
GN.addKnob(nuke.Text_Knob('', 'Rendering'))
# Add linked knobs.
linked_knob_names = [
"_grp-start_",
"use_limit", "first", "last",
"_grp-end_",
"Render"
]
linked_knob_names = []
# add input linked knobs and create group only if any input
if linked_knobs:
linked_knob_names.append("_grp-start_")
linked_knob_names.extend(linked_knobs)
linked_knob_names.append("_grp-end_")
linked_knob_names.append("Render")
for name in linked_knob_names:
if "_grp-start_" in name:
knob = nuke.Tab_Knob(
@ -481,13 +486,20 @@ def create_write_node(name, data, input=None, prenodes=None, review=True):
"rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP)
GN.addKnob(knob)
else:
link = nuke.Link_Knob("")
link.makeLink(write_node.name(), name)
link.setName(name)
if "Render" in name:
link.setLabel("Render Local")
link.setFlag(0x1000)
GN.addKnob(link)
if "___" in name:
# add devider
GN.addKnob(nuke.Text_Knob(""))
else:
# add linked knob by name
link = nuke.Link_Knob("")
link.makeLink(write_node.name(), name)
link.setName(name)
# make render
if "Render" in name:
link.setLabel("Render Local")
link.setFlag(0x1000)
GN.addKnob(link)
# adding write to read button
add_button_write_to_read(GN)

View file

@ -103,7 +103,8 @@ class CreateWritePrerender(plugin.PypeCreator):
write_data,
input=selected_node,
prenodes=[],
review=False)
review=False,
linked_knobs=["channels", "___", "first", "last", "use_limit"])
# relinking to collected connections
for i, input in enumerate(inputs):
@ -122,19 +123,9 @@ class CreateWritePrerender(plugin.PypeCreator):
w_node = n
write_node.end()
# add inner write node Tab
write_node.addKnob(nuke.Tab_Knob("WriteLinkedKnobs"))
# linking knobs to group property panel
linking_knobs = ["channels", "___", "first", "last", "use_limit"]
for k in linking_knobs:
if "___" in k:
write_node.addKnob(nuke.Text_Knob(''))
else:
lnk = nuke.Link_Knob(k)
lnk.makeLink(w_node.name(), k)
lnk.setName(k.replace('_', ' ').capitalize())
lnk.clearFlag(nuke.STARTLINE)
write_node.addKnob(lnk)
if self.presets.get("use_range_limit"):
w_node["use_limit"].setValue(True)
w_node["first"].setValue(nuke.root()["first_frame"].value())
w_node["last"].setValue(nuke.root()["last_frame"].value())
return write_node

View file

@ -100,6 +100,13 @@ class CreateWriteRender(plugin.PypeCreator):
"/{subset}.{frame}.{ext}")})
# add crop node to cut off all outside of format bounding box
# get width and height
try:
width, height = (selected_node.width(), selected_node.height())
except AttributeError:
actual_format = nuke.root().knob('format').value()
width, height = (actual_format.width(), actual_format.height())
_prenodes = [
{
"name": "Crop01",
@ -108,8 +115,8 @@ class CreateWriteRender(plugin.PypeCreator):
("box", [
0.0,
0.0,
selected_node.width(),
selected_node.height()
width,
height
])
],
"dependent": None

View file

@ -1,5 +1,6 @@
import os
import re
from pprint import pformat
import nuke
import pyblish.api
import openpype.api as pype
@ -17,6 +18,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
def process(self, instance):
_families_test = [instance.data["family"]] + instance.data["families"]
self.log.debug("_families_test: {}".format(_families_test))
node = None
for x in instance:
@ -133,22 +135,29 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
"outputDir": output_dir,
"ext": ext,
"label": label,
"handleStart": handle_start,
"handleEnd": handle_end,
"frameStart": first_frame + handle_start,
"frameEnd": last_frame - handle_end,
"frameStartHandle": first_frame,
"frameEndHandle": last_frame,
"outputType": output_type,
"colorspace": colorspace,
"deadlineChunkSize": deadlineChunkSize,
"deadlinePriority": deadlinePriority
})
if "prerender" in _families_test:
if self.is_prerender(_families_test):
instance.data.update({
"family": "prerender",
"families": []
"handleStart": 0,
"handleEnd": 0,
"frameStart": first_frame,
"frameEnd": last_frame,
"frameStartHandle": first_frame,
"frameEndHandle": last_frame,
})
else:
instance.data.update({
"handleStart": handle_start,
"handleEnd": handle_end,
"frameStart": first_frame + handle_start,
"frameEnd": last_frame - handle_end,
"frameStartHandle": first_frame,
"frameEndHandle": last_frame,
})
# * Add audio to instance if exists.
@ -170,4 +179,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin):
"filename": api.get_representation_path(repre_doc)
}]
self.log.debug("instance.data: {}".format(instance.data))
self.log.debug("instance.data: {}".format(pformat(instance.data)))
def is_prerender(self, families):
return next((f for f in families if "prerender" in f), None)

View file

@ -61,7 +61,6 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
hosts = ["nuke", "nukestudio"]
actions = [RepairCollectionActionToLocal, RepairCollectionActionToFarm]
def process(self, instance):
for repre in instance.data["representations"]:
@ -78,10 +77,10 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
collection = collections[0]
frame_length = int(
instance.data["frameEndHandle"]
- instance.data["frameStartHandle"] + 1
)
fstartH = instance.data["frameStartHandle"]
fendH = instance.data["frameEndHandle"]
frame_length = int(fendH - fstartH + 1)
if frame_length != 1:
if len(collections) != 1:
@ -95,7 +94,16 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
raise ValidationException(msg)
collected_frames_len = int(len(collection.indexes))
coll_start = min(collection.indexes)
coll_end = max(collection.indexes)
self.log.info("frame_length: {}".format(frame_length))
self.log.info("collected_frames_len: {}".format(
collected_frames_len))
self.log.info("fstartH-fendH: {}-{}".format(fstartH, fendH))
self.log.info(
"coll_start-coll_end: {}-{}".format(coll_start, coll_end))
self.log.info(
"len(collection.indexes): {}".format(collected_frames_len)
)
@ -103,8 +111,11 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
if ("slate" in instance.data["families"]) \
and (frame_length != collected_frames_len):
collected_frames_len -= 1
fstartH += 1
assert (collected_frames_len == frame_length), (
assert ((collected_frames_len >= frame_length)
and (coll_start <= fstartH)
and (coll_end >= fendH)), (
"{} missing frames. Use repair to render all frames"
).format(__name__)

View file

@ -0,0 +1,26 @@
import re
def get_unique_layer_name(layers, asset_name, subset_name):
"""
Gets all layer names and if 'asset_name_subset_name' is present, it
increases suffix by 1 (eg. creates unique layer name - for Loader)
Args:
layers (list) of dict with layers info (name, id etc.)
asset_name (string):
subset_name (string):
Returns:
(string): name_00X (without version)
"""
name = "{}_{}".format(asset_name, subset_name)
names = {}
for layer in layers:
layer_name = re.sub(r'_\d{3}$', '', layer.name)
if layer_name in names.keys():
names[layer_name] = names[layer_name] + 1
else:
names[layer_name] = 1
occurrences = names.get(name, 0)
return "{}_{:0>3d}".format(name, occurrences + 1)

View file

@ -1,7 +1,9 @@
from avalon import api, photoshop
import os
import re
from avalon import api, photoshop
from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
stub = photoshop.stub()
@ -15,8 +17,9 @@ class ImageLoader(api.Loader):
representations = ["*"]
def load(self, context, name=None, namespace=None, data=None):
layer_name = self._get_unique_layer_name(context["asset"]["name"],
name)
layer_name = get_unique_layer_name(stub.get_layers(),
context["asset"]["name"],
name)
with photoshop.maintained_selection():
layer = stub.import_smart_object(self.fname, layer_name)
@ -69,25 +72,3 @@ class ImageLoader(api.Loader):
def switch(self, container, representation):
self.update(container, representation)
def _get_unique_layer_name(self, asset_name, subset_name):
"""
Gets all layer names and if 'name' is present in them, increases
suffix by 1 (eg. creates unique layer name - for Loader)
Args:
name (string): in format asset_subset
Returns:
(string): name_00X (without version)
"""
name = "{}_{}".format(asset_name, subset_name)
names = {}
for layer in stub.get_layers():
layer_name = re.sub(r'_\d{3}$', '', layer.name)
if layer_name in names.keys():
names[layer_name] = names[layer_name] + 1
else:
names[layer_name] = 1
occurrences = names.get(name, 0)
return "{}_{:0>3d}".format(name, occurrences + 1)

View file

@ -0,0 +1,98 @@
import os
from avalon import api
from avalon import photoshop
from avalon.pipeline import get_representation_path_from_context
from avalon.vendor import qargparse
from openpype.lib import Anatomy
from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
stub = photoshop.stub()
class ImageFromSequenceLoader(api.Loader):
""" Load specifing image from sequence
Used only as quick load of reference file from a sequence.
Plain ImageLoader picks first frame from sequence.
Loads only existing files - currently not possible to limit loaders
to single select - multiselect. If user selects multiple repres, list
for all of them is provided, but selection is only single file.
This loader will be triggered multiple times, but selected name will
match only to proper path.
Loader doesnt do containerization as there is currently no data model
of 'frame of rendered files' (only rendered sequence), update would be
difficult.
"""
families = ["render"]
representations = ["*"]
options = []
def load(self, context, name=None, namespace=None, data=None):
if data.get("frame"):
self.fname = os.path.join(os.path.dirname(self.fname),
data["frame"])
if not os.path.exists(self.fname):
return
stub = photoshop.stub()
layer_name = get_unique_layer_name(stub.get_layers(),
context["asset"]["name"],
name)
with photoshop.maintained_selection():
layer = stub.import_smart_object(self.fname, layer_name)
self[:] = [layer]
namespace = namespace or layer_name
return namespace
@classmethod
def get_options(cls, repre_contexts):
"""
Returns list of files for selected 'repre_contexts'.
It returns only files with same extension as in context as it is
expected that context points to sequence of frames.
Returns:
(list) of qargparse.Choice
"""
files = []
for context in repre_contexts:
fname = get_representation_path_from_context(context)
_, file_extension = os.path.splitext(fname)
for file_name in os.listdir(os.path.dirname(fname)):
if not file_name.endswith(file_extension):
continue
files.append(file_name)
# return selection only if there is something
if not files or len(files) <= 1:
return []
return [
qargparse.Choice(
"frame",
label="Select specific file",
items=files,
default=0,
help="Which frame should be loaded?"
)
]
def update(self, container, representation):
"""No update possible, not containerized."""
pass
def remove(self, container):
"""No update possible, not containerized."""
pass

View file

@ -34,7 +34,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin):
# presets
batch_extensions = ["edl", "xml", "psd"]
default_families = ["ftrack"]
def process(self, context):
# get json paths from os and load them
@ -213,10 +212,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin):
subset = in_data["subset"]
# If instance data already contain families then use it
instance_families = in_data.get("families") or []
# Make sure default families are in instance
for default_family in self.default_families or []:
if default_family not in instance_families:
instance_families.append(default_family)
instance = context.create_instance(subset)
instance.data.update(

View file

@ -16,12 +16,12 @@ class CollectInstances(pyblish.api.InstancePlugin):
subsets = {
"referenceMain": {
"family": "review",
"families": ["clip", "ftrack"],
"families": ["clip"],
"extensions": [".mp4"]
},
"audioMain": {
"family": "audio",
"families": ["clip", "ftrack"],
"families": ["clip"],
"extensions": [".wav"],
},
"shotMain": {

View file

@ -1,29 +0,0 @@
"""
Requires:
Nothing
Provides:
Instance
"""
import pyblish.api
import logging
log = logging.getLogger("collector")
class CollectMatchmovePublish(pyblish.api.InstancePlugin):
"""
Collector with only one reason for its existence - remove 'ftrack'
family implicitly added by Standalone Publisher
"""
label = "Collect Matchmove - SA Publish"
order = pyblish.api.CollectorOrder
families = ["matchmove"]
hosts = ["standalonepublisher"]
def process(self, instance):
if "ftrack" in instance.data["families"]:
instance.data["families"].remove("ftrack")

View file

@ -66,7 +66,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin):
else:
# Convert to jpeg if not yet
full_input_path = os.path.join(thumbnail_repre["stagingDir"], file)
full_input_path = '"{}"'.format(full_input_path)
self.log.info("input {}".format(full_input_path))
full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1]

View file

@ -43,7 +43,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin):
self.log.warning("Cannot check for extension {}".format(ext))
return
frames = len(instance.data.get("representations", [None])[0]["files"])
files = instance.data.get("representations", [None])[0]["files"]
if isinstance(files, str):
files = [files]
frames = len(files)
err_msg = "Frame duration from DB:'{}' ". format(int(duration)) +\
" doesn't match number of files:'{}'".format(frames) +\

View file

@ -58,18 +58,14 @@ class CreateRenderlayer(plugin.Creator):
# Get currently selected layers
layers_data = lib.layers_data()
group_ids = set()
for layer in layers_data:
if layer["selected"]:
group_ids.add(layer["group_id"])
selected_layers = [
layer
for layer in layers_data
if layer["selected"]
]
# Return layer name if only one is selected
if len(group_ids) == 1:
group_id = list(group_ids)[0]
groups_data = lib.groups_data()
for group in groups_data:
if group["group_id"] == group_id:
return group["name"]
if len(selected_layers) == 1:
return selected_layers[0]["name"]
# Use defaults
if cls.defaults:

View file

@ -103,8 +103,6 @@ class CollectInstances(pyblish.api.ContextPlugin):
instance.data["layers"] = copy.deepcopy(
context.data["layersData"]
)
# Add ftrack family
instance.data["families"].append("ftrack")
elif family == "renderLayer":
instance = self.create_render_layer_instance(
@ -186,9 +184,6 @@ class CollectInstances(pyblish.api.ContextPlugin):
instance_data["layers"] = group_layers
# Add ftrack family
instance_data["families"].append("ftrack")
return context.create_instance(**instance_data)
def create_render_pass_instance(self, context, instance_data):

View file

@ -1,5 +1,6 @@
import os
import json
import tempfile
import pyblish.api
import avalon.api
@ -153,9 +154,45 @@ class CollectWorkfileData(pyblish.api.ContextPlugin):
"sceneMarkIn": int(mark_in_frame),
"sceneMarkInState": mark_in_state == "set",
"sceneMarkOut": int(mark_out_frame),
"sceneMarkOutState": mark_out_state == "set"
"sceneMarkOutState": mark_out_state == "set",
"sceneBgColor": self._get_bg_color()
}
self.log.debug(
"Scene data: {}".format(json.dumps(scene_data, indent=4))
)
context.data.update(scene_data)
def _get_bg_color(self):
"""Background color set on scene.
Is important for review exporting where scene bg color is used as
background.
"""
output_file = tempfile.NamedTemporaryFile(
mode="w", prefix="a_tvp_", suffix=".txt", delete=False
)
output_file.close()
output_filepath = output_file.name.replace("\\", "/")
george_script_lines = [
# Variable containing full path to output file
"output_path = \"{}\"".format(output_filepath),
"tv_background",
"bg_color = result",
# Write data to output file
(
"tv_writetextfile"
" \"strict\" \"append\" '\"'output_path'\"' bg_color"
)
]
george_script = "\n".join(george_script_lines)
lib.execute_george_through_file(george_script)
with open(output_filepath, "r") as stream:
data = stream.read()
os.remove(output_filepath)
data = data.strip()
if not data:
return None
return data.split(" ")

View file

@ -1,5 +1,6 @@
import os
import shutil
import copy
import tempfile
import pyblish.api
@ -13,6 +14,9 @@ class ExtractSequence(pyblish.api.Extractor):
hosts = ["tvpaint"]
families = ["review", "renderPass", "renderLayer"]
# Modifiable with settings
review_bg = [255, 255, 255, 255]
def process(self, instance):
self.log.info(
"* Processing instance \"{}\"".format(instance.data["label"])
@ -53,6 +57,8 @@ class ExtractSequence(pyblish.api.Extractor):
handle_start = instance.context.data["handleStart"]
handle_end = instance.context.data["handleEnd"]
scene_bg_color = instance.context.data["sceneBgColor"]
# --- Fallbacks ----------------------------------------------------
# This is required if validations of ranges are ignored.
# - all of this code won't change processing if range to render
@ -120,7 +126,8 @@ class ExtractSequence(pyblish.api.Extractor):
if instance.data["family"] == "review":
output_filenames, thumbnail_fullpath = self.render_review(
filename_template, output_dir, mark_in, mark_out
filename_template, output_dir, mark_in, mark_out,
scene_bg_color
)
else:
# Render output
@ -241,7 +248,9 @@ class ExtractSequence(pyblish.api.Extractor):
for path in repre_filepaths
]
def render_review(self, filename_template, output_dir, mark_in, mark_out):
def render_review(
self, filename_template, output_dir, mark_in, mark_out, scene_bg_color
):
""" Export images from TVPaint using `tv_savesequence` command.
Args:
@ -252,6 +261,8 @@ class ExtractSequence(pyblish.api.Extractor):
output_dir (str): Directory where files will be stored.
mark_in (int): Starting frame index from which export will begin.
mark_out (int): On which frame index export will end.
scene_bg_color (list): Bg color set in scene. Result of george
script command `tv_background`.
Retruns:
tuple: With 2 items first is list of filenames second is path to
@ -263,7 +274,11 @@ class ExtractSequence(pyblish.api.Extractor):
filename_template.format(frame=mark_in)
)
bg_color = self._get_review_bg_color()
george_script_lines = [
# Change bg color to color from settings
"tv_background \"color\" {} {} {}".format(*bg_color),
"tv_SaveMode \"PNG\"",
"export_path = \"{}\"".format(
first_frame_filepath.replace("\\", "/")
@ -272,6 +287,18 @@ class ExtractSequence(pyblish.api.Extractor):
mark_in, mark_out
)
]
if scene_bg_color:
# Change bg color back to previous scene bg color
_scene_bg_color = copy.deepcopy(scene_bg_color)
bg_type = _scene_bg_color.pop(0)
orig_color_command = [
"tv_background",
"\"{}\"".format(bg_type)
]
orig_color_command.extend(_scene_bg_color)
george_script_lines.append(" ".join(orig_color_command))
lib.execute_george_through_file("\n".join(george_script_lines))
first_frame_filepath = None
@ -291,12 +318,13 @@ class ExtractSequence(pyblish.api.Extractor):
if first_frame_filepath is None:
first_frame_filepath = filepath
thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg")
thumbnail_filepath = None
if first_frame_filepath and os.path.exists(first_frame_filepath):
thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg")
source_img = Image.open(first_frame_filepath)
thumbnail_obj = Image.new("RGB", source_img.size, (255, 255, 255))
thumbnail_obj.paste(source_img)
thumbnail_obj.save(thumbnail_filepath)
if source_img.mode.lower() != "rgb":
source_img = source_img.convert("RGB")
source_img.save(thumbnail_filepath)
return output_filenames, thumbnail_filepath
@ -392,12 +420,35 @@ class ExtractSequence(pyblish.api.Extractor):
if thumbnail_src_filepath and os.path.exists(thumbnail_src_filepath):
source_img = Image.open(thumbnail_src_filepath)
thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg")
thumbnail_obj = Image.new("RGB", source_img.size, (255, 255, 255))
thumbnail_obj.paste(source_img)
thumbnail_obj.save(thumbnail_filepath)
# Composite background only on rgba images
# - just making sure
if source_img.mode.lower() == "rgba":
bg_color = self._get_review_bg_color()
self.log.debug("Adding thumbnail background color {}.".format(
" ".join([str(val) for val in bg_color])
))
bg_image = Image.new("RGBA", source_img.size, bg_color)
thumbnail_obj = Image.alpha_composite(bg_image, source_img)
thumbnail_obj.convert("RGB").save(thumbnail_filepath)
else:
self.log.info((
"Source for thumbnail has mode \"{}\" (Expected: RGBA)."
" Can't use thubmanail background color."
).format(source_img.mode))
source_img.save(thumbnail_filepath)
return output_filenames, thumbnail_filepath
def _get_review_bg_color(self):
red = green = blue = 255
if self.review_bg:
if len(self.review_bg) == 4:
red, green, blue, _ = self.review_bg
elif len(self.review_bg) == 3:
red, green, blue = self.review_bg
return (red, green, blue)
def _render_layer(
self,
layer,

View file

@ -733,6 +733,9 @@ class Templates:
continue
default_key_values[key] = templates.pop(key)
# Pop "others" key before before expected keys are processed
other_templates = templates.pop("others") or {}
keys_by_subkey = {}
for sub_key, sub_value in templates.items():
key_values = {}
@ -740,7 +743,6 @@ class Templates:
key_values.update(sub_value)
keys_by_subkey[sub_key] = cls.prepare_inner_keys(key_values)
other_templates = templates.get("others") or {}
for sub_key, sub_value in other_templates.items():
if sub_key in keys_by_subkey:
log.warning((

View file

@ -179,7 +179,7 @@ class Application:
if group.enabled:
enabled = data.get("enabled", True)
self.enabled = enabled
self.use_python_2 = data["use_python_2"]
self.use_python_2 = data.get("use_python_2", False)
self.label = data.get("variant_label") or name
self.full_name = "/".join((group.name, name))
@ -191,26 +191,32 @@ class Application:
self.full_label = full_label
self._environment = data.get("environment") or {}
arguments = data.get("arguments")
if isinstance(arguments, dict):
arguments = arguments.get(platform.system().lower())
if not arguments:
arguments = []
self.arguments = arguments
if "executables" not in data:
self.executables = [
UndefinedApplicationExecutable()
]
return
_executables = data["executables"]
if isinstance(_executables, dict):
_executables = _executables.get(platform.system().lower())
if not _executables:
_executables = []
elif isinstance(_executables, dict):
_executables = _executables.get(platform.system().lower()) or []
_arguments = data["arguments"]
if not _arguments:
_arguments = []
elif isinstance(_arguments, dict):
_arguments = _arguments.get(platform.system().lower()) or []
executables = []
for executable in _executables:
executables.append(ApplicationExecutable(executable))
self.executables = executables
self.arguments = _arguments
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.full_name)
@ -454,6 +460,12 @@ class ApplicationExecutable:
if os.path.exists(_executable):
executable = _executable
# Try to format executable with environments
try:
executable = executable.format(**os.environ)
except Exception:
pass
self.executable_path = executable
def __str__(self):
@ -484,6 +496,27 @@ class ApplicationExecutable:
return bool(self._realpath())
class UndefinedApplicationExecutable(ApplicationExecutable):
"""Some applications do not require executable path from settings.
In that case this class is used to "fake" existing executable.
"""
def __init__(self):
pass
def __str__(self):
return self.__class__.__name__
def __repr__(self):
return "<{}>".format(self.__class__.__name__)
def as_args(self):
return []
def exists(self):
return True
@six.add_metaclass(ABCMeta)
class LaunchHook:
"""Abstract base class of launch hook."""
@ -1126,6 +1159,9 @@ def prepare_host_environments(data, implementation_envs=True):
def apply_project_environments_value(project_name, env, project_settings=None):
"""Apply project specific environments on passed environments.
The enviornments are applied on passed `env` argument value so it is not
required to apply changes back.
Args:
project_name (str): Name of project for which environemnts should be
received.
@ -1134,6 +1170,9 @@ def apply_project_environments_value(project_name, env, project_settings=None):
project_settings (dict): Project settings for passed project name.
Optional if project settings are already prepared.
Returns:
dict: Passed env values with applied project environments.
Raises:
KeyError: If project settings do not contain keys for project specific
environments.
@ -1144,10 +1183,9 @@ def apply_project_environments_value(project_name, env, project_settings=None):
project_settings = get_project_settings(project_name)
env_value = project_settings["global"]["project_environments"]
if not env_value:
return env
parsed = acre.parse(env_value)
return _merge_env(parsed, env)
if env_value:
env.update(_merge_env(acre.parse(env_value), env))
return env
def prepare_context_environments(data):
@ -1176,9 +1214,8 @@ def prepare_context_environments(data):
# Load project specific environments
project_name = project_doc["name"]
data["env"] = apply_project_environments_value(
project_name, data["env"]
)
# Apply project specific environments on current env value
apply_project_environments_value(project_name, data["env"])
app = data["app"]
workdir_data = get_workdir_data(

View file

@ -89,8 +89,13 @@ def ffprobe_streams(path_to_file, logger=None):
popen_stdout, popen_stderr = popen.communicate()
if popen_stdout:
logger.debug("ffprobe stdout: {}".format(popen_stdout))
logger.debug("FFprobe stdout:\n{}".format(
popen_stdout.decode("utf-8")
))
if popen_stderr:
logger.debug("ffprobe stderr: {}".format(popen_stderr))
logger.warning("FFprobe stderr:\n{}".format(
popen_stderr.decode("utf-8")
))
return json.loads(popen_stdout)["streams"]

View file

@ -2,7 +2,10 @@ import collections
import datetime
import ftrack_api
from openpype.modules.ftrack.lib import BaseEvent
from openpype.modules.ftrack.lib import (
BaseEvent,
query_custom_attributes
)
class PushFrameValuesToTaskEvent(BaseEvent):
@ -55,10 +58,6 @@ class PushFrameValuesToTaskEvent(BaseEvent):
if entity_info.get("entityType") != "task":
continue
# Skip `Task` entity type
if entity_info["entity_type"].lower() == "task":
continue
# Care only about changes of status
changes = entity_info.get("changes")
if not changes:
@ -74,6 +73,14 @@ class PushFrameValuesToTaskEvent(BaseEvent):
if project_id is None:
continue
# Skip `Task` entity type if parent didn't change
if entity_info["entity_type"].lower() == "task":
if (
"parent_id" not in changes
or changes["parent_id"]["new"] is None
):
continue
if project_id not in entities_info_by_project_id:
entities_info_by_project_id[project_id] = []
entities_info_by_project_id[project_id].append(entity_info)
@ -117,11 +124,24 @@ class PushFrameValuesToTaskEvent(BaseEvent):
))
return
interest_attributes = set(interest_attributes)
interest_entity_types = set(interest_entity_types)
# Separate value changes and task parent changes
_entities_info = []
task_parent_changes = []
for entity_info in entities_info:
if entity_info["entity_type"].lower() == "task":
task_parent_changes.append(entity_info)
else:
_entities_info.append(entity_info)
entities_info = _entities_info
# Filter entities info with changes
interesting_data, changed_keys_by_object_id = self.filter_changes(
session, event, entities_info, interest_attributes
)
if not interesting_data:
if not interesting_data and not task_parent_changes:
return
# Prepare object types
@ -131,6 +151,289 @@ class PushFrameValuesToTaskEvent(BaseEvent):
name_low = object_type["name"].lower()
object_types_by_name[name_low] = object_type
# NOTE it would be nice to check if `interesting_data` do not contain
# value changs of tasks that were created or moved
# - it is a complex way how to find out
if interesting_data:
self.process_attribute_changes(
session, object_types_by_name,
interesting_data, changed_keys_by_object_id,
interest_entity_types, interest_attributes
)
if task_parent_changes:
self.process_task_parent_change(
session, object_types_by_name, task_parent_changes,
interest_entity_types, interest_attributes
)
def process_task_parent_change(
self, session, object_types_by_name, task_parent_changes,
interest_entity_types, interest_attributes
):
"""Push custom attribute values if task parent has changed.
Parent is changed if task is created or if is moved under different
entity. We don't care about all task changes only about those that
have it's parent in interest types (from settings).
Tasks hierarchical value should be unset or set based on parents
real hierarchical value and non hierarchical custom attribute value
should be set to hierarchical value.
"""
# Store task ids which were created or moved under parent with entity
# type defined in settings (interest_entity_types).
task_ids = set()
# Store parent ids of matching task ids
matching_parent_ids = set()
# Store all entity ids of all entities to be able query hierarchical
# values.
whole_hierarchy_ids = set()
# Store parent id of each entity id
parent_id_by_entity_id = {}
for entity_info in task_parent_changes:
# Ignore entities with less parents than 2
# NOTE entity itself is also part of "parents" value
parents = entity_info.get("parents") or []
if len(parents) < 2:
continue
parent_info = parents[1]
# Check if parent has entity type we care about.
if parent_info["entity_type"] not in interest_entity_types:
continue
task_ids.add(entity_info["entityId"])
matching_parent_ids.add(parent_info["entityId"])
# Store whole hierarchi of task entity
prev_id = None
for item in parents:
item_id = item["entityId"]
whole_hierarchy_ids.add(item_id)
if prev_id is None:
prev_id = item_id
continue
parent_id_by_entity_id[prev_id] = item_id
if item["entityType"] == "show":
break
prev_id = item_id
# Just skip if nothing is interesting for our settings
if not matching_parent_ids:
return
# Query object type ids of parent ids for custom attribute
# definitions query
entities = session.query(
"select object_type_id from TypedContext where id in ({})".format(
self.join_query_keys(matching_parent_ids)
)
)
# Prepare task object id
task_object_id = object_types_by_name["task"]["id"]
# All object ids for which we're querying custom attribute definitions
object_type_ids = set()
object_type_ids.add(task_object_id)
for entity in entities:
object_type_ids.add(entity["object_type_id"])
attrs_by_obj_id, hier_attrs = self.attrs_configurations(
session, object_type_ids, interest_attributes
)
# Skip if all task attributes are not available
task_attrs = attrs_by_obj_id.get(task_object_id)
if not task_attrs:
return
# Skip attributes that is not in both hierarchical and nonhierarchical
# TODO be able to push values if hierarchical is available
for key in interest_attributes:
if key not in hier_attrs:
task_attrs.pop(key, None)
elif key not in task_attrs:
hier_attrs.pop(key)
# Skip if nothing remained
if not task_attrs:
return
# Do some preparations for custom attribute values query
attr_key_by_id = {}
nonhier_id_by_key = {}
hier_attr_ids = []
for key, attr_id in hier_attrs.items():
attr_key_by_id[attr_id] = key
hier_attr_ids.append(attr_id)
conf_ids = list(hier_attr_ids)
for key, attr_id in task_attrs.items():
attr_key_by_id[attr_id] = key
nonhier_id_by_key[key] = attr_id
conf_ids.append(attr_id)
# Query custom attribute values
# - result does not contain values for all entities only result of
# query callback to ftrack server
result = query_custom_attributes(
session, conf_ids, whole_hierarchy_ids
)
# Prepare variables where result will be stored
# - hierachical values should not contain attribute with value by
# default
hier_values_by_entity_id = {
entity_id: {}
for entity_id in whole_hierarchy_ids
}
# - real values of custom attributes
values_by_entity_id = {
entity_id: {
attr_id: None
for attr_id in conf_ids
}
for entity_id in whole_hierarchy_ids
}
for item in result:
attr_id = item["configuration_id"]
entity_id = item["entity_id"]
value = item["value"]
values_by_entity_id[entity_id][attr_id] = value
if attr_id in hier_attr_ids and value is not None:
hier_values_by_entity_id[entity_id][attr_id] = value
# Prepare values for all task entities
# - going through all parents and storing first value value
# - store None to those that are already known that do not have set
# value at all
for task_id in tuple(task_ids):
for attr_id in hier_attr_ids:
entity_ids = []
value = None
entity_id = task_id
while value is None:
entity_value = hier_values_by_entity_id[entity_id]
if attr_id in entity_value:
value = entity_value[attr_id]
if value is None:
break
if value is None:
entity_ids.append(entity_id)
entity_id = parent_id_by_entity_id.get(entity_id)
if entity_id is None:
break
for entity_id in entity_ids:
hier_values_by_entity_id[entity_id][attr_id] = value
# Prepare changes to commit
changes = []
for task_id in tuple(task_ids):
parent_id = parent_id_by_entity_id[task_id]
for attr_id in hier_attr_ids:
attr_key = attr_key_by_id[attr_id]
nonhier_id = nonhier_id_by_key[attr_key]
# Real value of hierarchical attribute on parent
# - If is none then should be unset
real_parent_value = values_by_entity_id[parent_id][attr_id]
# Current hierarchical value of a task
# - Will be compared to real parent value
hier_value = hier_values_by_entity_id[task_id][attr_id]
# Parent value that can be inherited from it's parent entity
parent_value = hier_values_by_entity_id[parent_id][attr_id]
# Task value of nonhierarchical custom attribute
nonhier_value = values_by_entity_id[task_id][nonhier_id]
if real_parent_value != hier_value:
changes.append({
"new_value": real_parent_value,
"attr_id": attr_id,
"entity_id": task_id,
"attr_key": attr_key
})
if parent_value != nonhier_value:
changes.append({
"new_value": parent_value,
"attr_id": nonhier_id,
"entity_id": task_id,
"attr_key": attr_key
})
self._commit_changes(session, changes)
def _commit_changes(self, session, changes):
uncommited_changes = False
for idx, item in enumerate(changes):
new_value = item["new_value"]
attr_id = item["attr_id"]
entity_id = item["entity_id"]
attr_key = item["attr_key"]
entity_key = collections.OrderedDict()
entity_key["configuration_id"] = attr_id
entity_key["entity_id"] = entity_id
self._cached_changes.append({
"attr_key": attr_key,
"entity_id": entity_id,
"value": new_value,
"time": datetime.datetime.now()
})
if new_value is None:
op = ftrack_api.operation.DeleteEntityOperation(
"CustomAttributeValue",
entity_key
)
else:
op = ftrack_api.operation.UpdateEntityOperation(
"ContextCustomAttributeValue",
entity_key,
"value",
ftrack_api.symbol.NOT_SET,
new_value
)
session.recorded_operations.push(op)
self.log.info((
"Changing Custom Attribute \"{}\" to value"
" \"{}\" on entity: {}"
).format(attr_key, new_value, entity_id))
if (idx + 1) % 20 == 0:
uncommited_changes = False
try:
session.commit()
except Exception:
session.rollback()
self.log.warning(
"Changing of values failed.", exc_info=True
)
else:
uncommited_changes = True
if uncommited_changes:
try:
session.commit()
except Exception:
session.rollback()
self.log.warning("Changing of values failed.", exc_info=True)
def process_attribute_changes(
self, session, object_types_by_name,
interesting_data, changed_keys_by_object_id,
interest_entity_types, interest_attributes
):
# Prepare task object id
task_object_id = object_types_by_name["task"]["id"]
@ -216,13 +519,13 @@ class PushFrameValuesToTaskEvent(BaseEvent):
task_entity_ids.add(task_id)
parent_id_by_task_id[task_id] = task_entity["parent_id"]
self.finalize(
self.finalize_attribute_changes(
session, interesting_data,
changed_keys, attrs_by_obj_id, hier_attrs,
task_entity_ids, parent_id_by_task_id
)
def finalize(
def finalize_attribute_changes(
self, session, interesting_data,
changed_keys, attrs_by_obj_id, hier_attrs,
task_entity_ids, parent_id_by_task_id
@ -248,6 +551,7 @@ class PushFrameValuesToTaskEvent(BaseEvent):
session, attr_ids, entity_ids, task_entity_ids, hier_attrs
)
changes = []
for entity_id, current_values in current_values_by_id.items():
parent_id = parent_id_by_task_id.get(entity_id)
if not parent_id:
@ -272,39 +576,13 @@ class PushFrameValuesToTaskEvent(BaseEvent):
if new_value == old_value:
continue
entity_key = collections.OrderedDict()
entity_key["configuration_id"] = attr_id
entity_key["entity_id"] = entity_id
self._cached_changes.append({
"attr_key": attr_key,
changes.append({
"new_value": new_value,
"attr_id": attr_id,
"entity_id": entity_id,
"value": new_value,
"time": datetime.datetime.now()
"attr_key": attr_key
})
if new_value is None:
op = ftrack_api.operation.DeleteEntityOperation(
"CustomAttributeValue",
entity_key
)
else:
op = ftrack_api.operation.UpdateEntityOperation(
"ContextCustomAttributeValue",
entity_key,
"value",
ftrack_api.symbol.NOT_SET,
new_value
)
session.recorded_operations.push(op)
self.log.info((
"Changing Custom Attribute \"{}\" to value"
" \"{}\" on entity: {}"
).format(attr_key, new_value, entity_id))
try:
session.commit()
except Exception:
session.rollback()
self.log.warning("Changing of values failed.", exc_info=True)
self._commit_changes(session, changes)
def filter_changes(
self, session, event, entities_info, interest_attributes

View file

@ -66,15 +66,7 @@ class VersionToTaskStatus(BaseEvent):
))
return
_status_mapping = event_settings["mapping"]
if not _status_mapping:
self.log.debug(
"Project \"{}\" does not have set mapping for {}".format(
project_name, self.__class__.__name__
)
)
return
_status_mapping = event_settings["mapping"] or {}
status_mapping = {
key.lower(): value
for key, value in _status_mapping.items()

View file

@ -13,7 +13,8 @@ from .custom_attributes import (
default_custom_attributes_definition,
app_definitions_from_app_manager,
tool_definitions_from_app_manager,
get_openpype_attr
get_openpype_attr,
query_custom_attributes
)
from . import avalon_sync
@ -37,6 +38,7 @@ __all__ = (
"app_definitions_from_app_manager",
"tool_definitions_from_app_manager",
"get_openpype_attr",
"query_custom_attributes",
"avalon_sync",

View file

@ -402,16 +402,18 @@ class SyncEntitiesFactory:
items = []
items.append({
"type": "label",
"value": "# Can't access Custom attribute <{}>".format(
CUST_ATTR_ID_KEY
)
"value": (
"# Can't access Custom attribute: <b>\"{}\"</b>"
).format(CUST_ATTR_ID_KEY)
})
items.append({
"type": "label",
"value": (
"<p>- Check if user \"{}\" has permissions"
" to access the Custom attribute</p>"
).format(self._api_key)
"<p>- Check if your User and API key has permissions"
" to access the Custom attribute."
"<br>Username:\"{}\""
"<br>API key:\"{}\"</p>"
).format(self._api_user, self._api_key)
})
items.append({
"type": "label",

View file

@ -81,3 +81,60 @@ def get_openpype_attr(session, split_hierarchical=True, query_keys=None):
return custom_attributes, hier_custom_attributes
return custom_attributes
def join_query_keys(keys):
"""Helper to join keys to query."""
return ",".join(["\"{}\"".format(key) for key in keys])
def query_custom_attributes(session, conf_ids, entity_ids, table_name=None):
"""Query custom attribute values from ftrack database.
Using ftrack call method result may differ based on used table name and
version of ftrack server.
Args:
session(ftrack_api.Session): Connected ftrack session.
conf_id(list, set, tuple): Configuration(attribute) ids which are
queried.
entity_ids(list, set, tuple): Entity ids for which are values queried.
table_name(str): Table nam from which values are queried. Not
recommended to change until you know what it means.
"""
output = []
# Just skip
if not conf_ids or not entity_ids:
return output
if table_name is None:
table_name = "ContextCustomAttributeValue"
# Prepare values to query
attributes_joined = join_query_keys(conf_ids)
attributes_len = len(conf_ids)
# Query values in chunks
chunk_size = int(5000 / attributes_len)
# Make sure entity_ids is `list` for chunk selection
entity_ids = list(entity_ids)
for idx in range(0, len(entity_ids), chunk_size):
entity_ids_joined = join_query_keys(
entity_ids[idx:idx + chunk_size]
)
call_expr = [{
"action": "query",
"expression": (
"select value, entity_id from {}"
" where entity_id in ({}) and configuration_id in ({})"
).format(table_name, entity_ids_joined, attributes_joined)
}]
if hasattr(session, "call"):
[result] = session.call(call_expr)
else:
[result] = session._call(call_expr)
for item in result["data"]:
output.append(item)
return output

View file

@ -1,29 +1,107 @@
"""
Requires:
none
Provides:
instance -> families ([])
"""
import pyblish.api
import avalon.api
from openpype.lib.plugin_tools import filter_profiles
class CollectFtrackFamilies(pyblish.api.InstancePlugin):
"""Collect family for ftrack publishing
Add ftrack family to those instance that should be published to ftrack
class CollectFtrackFamily(pyblish.api.InstancePlugin):
"""
Adds explicitly 'ftrack' to families to upload instance to FTrack.
order = pyblish.api.CollectorOrder + 0.3
label = 'Add ftrack family'
families = ["model",
"setdress",
"model",
"animation",
"look",
"rig",
"camera"
]
hosts = ["maya"]
Uses selection by combination of hosts/families/tasks names via
profiles resolution.
Triggered everywhere, checks instance against configured.
Checks advanced filtering which works on 'families' not on main
'family', as some variants dynamically resolves addition of ftrack
based on 'families' (editorial drives it by presence of 'review')
"""
label = "Collect Ftrack Family"
order = pyblish.api.CollectorOrder + 0.4998
profiles = None
def process(self, instance):
if not self.profiles:
self.log.warning("No profiles present for adding Ftrack family")
return
# make ftrack publishable
if instance.data.get('families'):
instance.data['families'].append('ftrack')
task_name = instance.data.get("task",
avalon.api.Session["AVALON_TASK"])
host_name = avalon.api.Session["AVALON_APP"]
family = instance.data["family"]
filtering_criteria = {
"hosts": host_name,
"families": family,
"tasks": task_name
}
profile = filter_profiles(self.profiles, filtering_criteria,
logger=self.log)
if profile:
families = instance.data.get("families")
add_ftrack_family = profile["add_ftrack_family"]
additional_filters = profile.get("additional_filters")
if additional_filters:
add_ftrack_family = self._get_add_ftrack_f_from_addit_filters(
additional_filters,
families,
add_ftrack_family
)
if add_ftrack_family:
self.log.debug("Adding ftrack family for '{}'".
format(instance.data.get("family")))
if families and "ftrack" not in families:
instance.data["families"].append("ftrack")
else:
instance.data["families"] = ["ftrack"]
else:
instance.data['families'] = ['ftrack']
self.log.debug("Instance '{}' doesn't match any profile".format(
instance.data.get("family")))
def _get_add_ftrack_f_from_addit_filters(self,
additional_filters,
families,
add_ftrack_family):
"""
Compares additional filters - working on instance's families.
Triggered for more detailed filtering when main family matches,
but content of 'families' actually matter.
(For example 'review' in 'families' should result in adding to
Ftrack)
Args:
additional_filters (dict) - from Setting
families (list) - subfamilies
add_ftrack_family (bool) - add ftrack to families if True
"""
override_filter = None
override_filter_value = -1
for additional_filter in additional_filters:
filter_families = set(additional_filter["families"])
valid = filter_families <= set(families) # issubset
if not valid:
continue
value = len(filter_families)
if value > override_filter_value:
override_filter = additional_filter
override_filter_value = value
if override_filter:
add_ftrack_family = override_filter["add_ftrack_family"]
return add_ftrack_family

View file

@ -114,6 +114,7 @@ class LocalSettingsAction(PypeModule, ITrayAction):
# Tray attributes
self.settings_window = None
self._first_trigger = True
def connect_with_modules(self, *_a, **_kw):
return
@ -153,6 +154,9 @@ class LocalSettingsAction(PypeModule, ITrayAction):
self.settings_window.raise_()
self.settings_window.activateWindow()
# Reset content if was not visible
if not was_visible:
# Do not reset if it's first trigger of action
if self._first_trigger:
self._first_trigger = False
elif not was_visible:
# Reset content if was not visible
self.settings_window.reset()

View file

@ -975,20 +975,52 @@ class ExtractReview(pyblish.api.InstancePlugin):
# NOTE Skipped using instance's resolution
full_input_path_single_file = temp_data["full_input_path_single_file"]
input_data = ffprobe_streams(
full_input_path_single_file, self.log
)[0]
input_width = int(input_data["width"])
input_height = int(input_data["height"])
try:
streams = ffprobe_streams(
full_input_path_single_file, self.log
)
except Exception:
raise AssertionError((
"FFprobe couldn't read information about input file: \"{}\""
).format(full_input_path_single_file))
# Try to find first stream with defined 'width' and 'height'
# - this is to avoid order of streams where audio can be as first
# - there may be a better way (checking `codec_type`?)
input_width = None
input_height = None
for stream in streams:
if "width" in stream and "height" in stream:
input_width = int(stream["width"])
input_height = int(stream["height"])
break
# Raise exception of any stream didn't define input resolution
if input_width is None:
raise AssertionError((
"FFprobe couldn't read resolution from input file: \"{}\""
).format(full_input_path_single_file))
# NOTE Setting only one of `width` or `heigth` is not allowed
# - settings value can't have None but has value of 0
output_width = output_def.get("width") or None
output_height = output_def.get("height") or None
# Overscal color
overscan_color_value = "black"
overscan_color = output_def.get("overscan_color")
if overscan_color:
bg_red, bg_green, bg_blue, _ = overscan_color
overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format(
bg_red, bg_green, bg_blue
)
self.log.debug("Overscan color: `{}`".format(overscan_color_value))
# Convert overscan value video filters
overscan_crop = output_def.get("overscan_crop")
overscan = OverscanCrop(input_width, input_height, overscan_crop)
overscan = OverscanCrop(
input_width, input_height, overscan_crop, overscan_color_value
)
overscan_crop_filters = overscan.video_filters()
# Add overscan filters to filters if are any and modify input
# resolution by it's values
@ -1158,9 +1190,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
"scale={}x{}:flags=lanczos".format(
width_scale, height_scale
),
"pad={}:{}:{}:{}:black".format(
"pad={}:{}:{}:{}:{}".format(
output_width, output_height,
width_half_pad, height_half_pad
width_half_pad, height_half_pad,
overscan_color_value
),
"setsar=1"
])
@ -1707,12 +1740,15 @@ class OverscanCrop:
item_regex = re.compile(r"([\+\-])?([0-9]+)(.+)?")
relative_source_regex = re.compile(r"%([\+\-])")
def __init__(self, input_width, input_height, string_value):
def __init__(
self, input_width, input_height, string_value, overscal_color=None
):
# Make sure that is not None
string_value = string_value or ""
self.input_width = input_width
self.input_height = input_height
self.overscal_color = overscal_color
width, height = self._convert_string_to_values(string_value)
self._width_value = width
@ -1767,16 +1803,22 @@ class OverscanCrop:
elif width >= self.input_width and height >= self.input_height:
output.append(
"pad={}:{}:(iw-ow)/2:(ih-oh)/2".format(width, height)
"pad={}:{}:(iw-ow)/2:(ih-oh)/2:{}".format(
width, height, self.overscal_color
)
)
elif width > self.input_width and height < self.input_height:
output.append("crop=iw:{}".format(height))
output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2".format(width))
output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2:{}".format(
width, self.overscal_color
))
elif width < self.input_width and height > self.input_height:
output.append("crop={}:ih".format(width))
output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2".format(height))
output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2:{}".format(
height, self.overscal_color
))
return output

View file

@ -26,9 +26,23 @@ class ExtractReviewSlate(openpype.api.Extractor):
slate_path = inst_data.get("slateFrame")
ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg")
slate_stream = openpype.lib.ffprobe_streams(slate_path, self.log)[0]
slate_width = slate_stream["width"]
slate_height = slate_stream["height"]
slate_streams = openpype.lib.ffprobe_streams(slate_path, self.log)
# Try to find first stream with defined 'width' and 'height'
# - this is to avoid order of streams where audio can be as first
# - there may be a better way (checking `codec_type`?)+
slate_width = None
slate_height = None
for slate_stream in slate_streams:
if "width" in slate_stream and "height" in slate_stream:
slate_width = int(slate_stream["width"])
slate_height = int(slate_stream["height"])
break
# Raise exception of any stream didn't define input resolution
if slate_width is None:
raise AssertionError((
"FFprobe couldn't read resolution from input file: \"{}\""
).format(slate_path))
if "reviewToWidth" in inst_data:
use_legacy_code = True
@ -309,16 +323,29 @@ class ExtractReviewSlate(openpype.api.Extractor):
)
return codec_args
codec_name = streams[0].get("codec_name")
# Try to find first stream that is not an audio
no_audio_stream = None
for stream in streams:
if stream.get("codec_type") != "audio":
no_audio_stream = stream
break
if no_audio_stream is None:
self.log.warning((
"Couldn't find stream that is not an audio from file \"{}\""
).format(full_input_path))
return codec_args
codec_name = no_audio_stream.get("codec_name")
if codec_name:
codec_args.append("-codec:v {}".format(codec_name))
profile_name = streams[0].get("profile")
profile_name = no_audio_stream.get("profile")
if profile_name:
profile_name = profile_name.replace(" ", "_").lower()
codec_args.append("-profile:v {}".format(profile_name))
pix_fmt = streams[0].get("pix_fmt")
pix_fmt = no_audio_stream.get("pix_fmt")
if pix_fmt:
codec_args.append("-pix_fmt {}".format(pix_fmt))
return codec_args

View file

@ -78,6 +78,10 @@
{
"name": "colorspace",
"value": "linear"
},
{
"name": "create_directories",
"value": "True"
}
]
},
@ -114,6 +118,10 @@
{
"name": "colorspace",
"value": "linear"
},
{
"name": "create_directories",
"value": "True"
}
]
}

View file

@ -200,6 +200,88 @@
}
},
"publish": {
"CollectFtrackFamily": {
"enabled": true,
"profiles": [
{
"hosts": [
"standalonepublisher"
],
"families": [],
"tasks": [],
"add_ftrack_family": true,
"advanced_filtering": []
},
{
"hosts": [
"standalonepublisher"
],
"families": [
"matchmove",
"shot"
],
"tasks": [],
"add_ftrack_family": false,
"advanced_filtering": []
},
{
"hosts": [
"standalonepublisher"
],
"families": [
"review",
"plate"
],
"tasks": [],
"add_ftrack_family": false,
"advanced_filtering": [
{
"families": [
"clip",
"review"
],
"add_ftrack_family": true
}
]
},
{
"hosts": [
"maya"
],
"families": [
"model",
"setdress",
"animation",
"look",
"rig",
"camera"
],
"tasks": [],
"add_ftrack_family": true,
"advanced_filtering": []
},
{
"hosts": [
"tvpaint"
],
"families": [
"renderPass"
],
"tasks": [],
"add_ftrack_family": false,
"advanced_filtering": []
},
{
"hosts": [
"tvpaint"
],
"families": [],
"tasks": [],
"add_ftrack_family": true,
"advanced_filtering": []
}
]
},
"IntegrateFtrackNote": {
"enabled": true,
"note_with_intent_template": "{intent}: {comment}",

View file

@ -56,6 +56,12 @@
]
},
"overscan_crop": "",
"overscan_color": [
0,
0,
0,
255
],
"width": 0,
"height": 0,
"bg_color": [
@ -226,6 +232,17 @@
],
"tasks": [],
"template": "{family}{Task}_{Render_layer}_{Render_pass}"
},
{
"families": [
"review",
"workfile"
],
"hosts": [
"tvpaint"
],
"tasks": [],
"template": "{family}{Task}"
}
]
},

View file

@ -5,6 +5,11 @@
".*"
]
},
"ValidateContainers": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateSceneSettings": {
"enabled": true,
"optional": true,

View file

@ -0,0 +1,9 @@
{
"publish": {
"ValidateContainers": {
"enabled": true,
"optional": true,
"active": true
}
}
}

View file

@ -127,6 +127,11 @@
"CollectMayaRender": {
"sync_workfile_version": false
},
"ValidateContainers": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateShaderName": {
"enabled": false,
"regex": "(?P<asset>.*)_(.*)_SHD"

View file

@ -13,13 +13,19 @@
"fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}"
},
"CreateWritePrerender": {
"fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}"
"fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}",
"use_range_limit": true
}
},
"publish": {
"PreCollectNukeInstances": {
"sync_workfile_version": true
},
"ValidateContainers": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateKnobs": {
"enabled": false,
"knobs": {

View file

@ -7,6 +7,11 @@
}
},
"publish": {
"ValidateContainers": {
"enabled": true,
"optional": true,
"active": true
},
"ExtractImage": {
"formats": [
"png",

View file

@ -105,16 +105,23 @@
"label": "Render",
"family": "render",
"icon": "image",
"defaults": ["Animation", "Lighting", "Lookdev", "Compositing"],
"defaults": [
"Animation",
"Lighting",
"Lookdev",
"Compositing"
],
"help": "Rendered images or video files"
},
"create_mov_batch": {
"name": "mov_batch",
"label": "Batch Mov",
"family": "render_mov_batch",
"icon": "image",
"defaults": ["Main"],
"help": "Process multiple Mov files and publish them for layout and comp."
"name": "mov_batch",
"label": "Batch Mov",
"family": "render_mov_batch",
"icon": "image",
"defaults": [
"Main"
],
"help": "Process multiple Mov files and publish them for layout and comp."
},
"__dynamic_keys_labels__": {
"create_workfile": "Workfile",

View file

@ -1,5 +1,13 @@
{
"publish": {
"ExtractSequence": {
"review_bg": [
255,
255,
255,
255
]
},
"ValidateProjectSettings": {
"enabled": true,
"optional": true,

View file

@ -807,7 +807,6 @@
"environment": {},
"variants": {
"2-83": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Blender Foundation\\Blender 2.83\\blender.exe"
@ -829,7 +828,6 @@
"environment": {}
},
"2-90": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Blender Foundation\\Blender 2.90\\blender.exe"
@ -851,7 +849,6 @@
"environment": {}
},
"2-91": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Blender Foundation\\Blender 2.91\\blender.exe"
@ -891,7 +888,6 @@
"20": {
"enabled": true,
"variant_label": "20",
"use_python_2": false,
"executables": {
"windows": [],
"darwin": [],
@ -907,7 +903,6 @@
"17": {
"enabled": true,
"variant_label": "17",
"use_python_2": false,
"executables": {
"windows": [],
"darwin": [
@ -932,7 +927,6 @@
"environment": {},
"variants": {
"animation_11-64bits": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\TVPaint Developpement\\TVPaint Animation 11 (64bits)\\TVPaint Animation 11 (64bits).exe"
@ -948,7 +942,6 @@
"environment": {}
},
"animation_11-32bits": {
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files (x86)\\TVPaint Developpement\\TVPaint Animation 11 (32bits)\\TVPaint Animation 11 (32bits).exe"
@ -982,7 +975,6 @@
"2020": {
"enabled": true,
"variant_label": "2020",
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe"
@ -1000,7 +992,6 @@
"2021": {
"enabled": true,
"variant_label": "2021",
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe"
@ -1030,7 +1021,6 @@
"2020": {
"enabled": true,
"variant_label": "2020",
"use_python_2": false,
"executables": {
"windows": [
""
@ -1048,7 +1038,6 @@
"2021": {
"enabled": true,
"variant_label": "2021",
"use_python_2": false,
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe After Effects 2021\\Support Files\\AfterFX.exe"
@ -1101,16 +1090,6 @@
"variants": {
"4-26": {
"use_python_2": false,
"executables": {
"windows": [],
"darwin": [],
"linux": []
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": {}
}
}

View file

@ -101,6 +101,7 @@ from .color_entity import ColorEntity
from .enum_entity import (
BaseEnumEntity,
EnumEntity,
HostsEnumEntity,
AppsEnumEntity,
ToolsEnumEntity,
TaskTypeEnumEntity,
@ -153,6 +154,7 @@ __all__ = (
"BaseEnumEntity",
"EnumEntity",
"HostsEnumEntity",
"AppsEnumEntity",
"ToolsEnumEntity",
"TaskTypeEnumEntity",

View file

@ -279,6 +279,11 @@ class BaseItemEntity(BaseEntity):
self, "Dynamic entity can't require restart."
)
@abstractproperty
def root_key(self):
"""Root is represented as this dictionary key."""
pass
@abstractmethod
def set_override_state(self, state):
"""Set override state and trigger it on children.
@ -866,6 +871,10 @@ class ItemEntity(BaseItemEntity):
"""Call save on root item."""
self.root_item.save()
@property
def root_key(self):
return self.root_item.root_key
def schema_validations(self):
if not self.label and self.use_label_wrap:
reason = (
@ -885,7 +894,11 @@ class ItemEntity(BaseItemEntity):
def create_schema_object(self, *args, **kwargs):
"""Reference method for creation of entities defined in RootEntity."""
return self.root_item.create_schema_object(*args, **kwargs)
return self.schema_hub.create_schema_object(*args, **kwargs)
@property
def schema_hub(self):
return self.root_item.schema_hub
def get_entity_from_path(self, path):
return self.root_item.get_entity_from_path(path)

View file

@ -12,6 +12,17 @@ class ColorEntity(InputEntity):
def _item_initalization(self):
self.valid_value_types = (list, )
self.value_on_not_set = [0, 0, 0, 255]
self.use_alpha = self.schema_data.get("use_alpha", True)
def set_override_state(self, *args, **kwargs):
super(ColorEntity, self).set_override_state(*args, **kwargs)
value = self._current_value
if (
not self.use_alpha
and isinstance(value, list)
and len(value) == 4
):
value[3] = 255
def convert_to_valid_type(self, value):
"""Conversion to valid type.
@ -51,4 +62,8 @@ class ColorEntity(InputEntity):
).format(value)
raise BaseInvalidValueType(reason, self.path)
new_value.append(item)
# Make sure
if not self.use_alpha:
new_value[3] = 255
return new_value

View file

@ -1,4 +1,5 @@
import copy
import collections
from .lib import (
WRAPPER_TYPES,
@ -138,7 +139,16 @@ class DictImmutableKeysEntity(ItemEntity):
method when handling gui wrappers.
"""
added_children = []
for children_schema in schema_data["children"]:
children_deque = collections.deque()
for _children_schema in schema_data["children"]:
children_schemas = self.schema_hub.resolve_schema_data(
_children_schema
)
for children_schema in children_schemas:
children_deque.append(children_schema)
while children_deque:
children_schema = children_deque.popleft()
if children_schema["type"] in WRAPPER_TYPES:
_children_schema = copy.deepcopy(children_schema)
wrapper_children = self._add_children(

View file

@ -101,6 +101,78 @@ class EnumEntity(BaseEnumEntity):
super(EnumEntity, self).schema_validations()
class HostsEnumEntity(BaseEnumEntity):
"""Enumeration of host names.
Enum items are hardcoded in definition of the entity.
Hosts enum can have defined empty value as valid option which is
represented by empty string. Schema key to set this option is
`use_empty_value` (true/false). And to set label of empty value set
`empty_label` (string).
Enum can have single and multiselection.
NOTE:
Host name is not the same as application name. Host name defines
implementation instead of application name.
"""
schema_types = ["hosts-enum"]
def _item_initalization(self):
self.multiselection = self.schema_data.get("multiselection", True)
self.use_empty_value = self.schema_data.get(
"use_empty_value", not self.multiselection
)
custom_labels = self.schema_data.get("custom_labels") or {}
host_names = [
"aftereffects",
"blender",
"celaction",
"fusion",
"harmony",
"hiero",
"houdini",
"maya",
"nuke",
"photoshop",
"resolve",
"tvpaint",
"unreal"
]
if self.use_empty_value:
host_names.insert(0, "")
# Add default label for empty value if not available
if "" not in custom_labels:
custom_labels[""] = "< without host >"
# These are hardcoded there is not list of available host in OpenPype
enum_items = []
valid_keys = set()
for key in host_names:
label = custom_labels.get(key, key)
valid_keys.add(key)
enum_items.append({key: label})
self.enum_items = enum_items
self.valid_keys = valid_keys
if self.multiselection:
self.valid_value_types = (list, )
self.value_on_not_set = []
else:
for key in valid_keys:
if self.value_on_not_set is NOT_SET:
self.value_on_not_set = key
break
self.valid_value_types = (STRING_TYPE, )
# GUI attribute
self.placeholder = self.schema_data.get("placeholder")
class AppsEnumEntity(BaseEnumEntity):
schema_types = ["apps-enum"]

View file

@ -2,6 +2,7 @@ import os
import re
import json
import copy
import inspect
from .exceptions import (
SchemaTemplateMissingKeys,
@ -25,335 +26,6 @@ TEMPLATE_METADATA_KEYS = (
template_key_pattern = re.compile(r"(\{.*?[^{0]*\})")
def _pop_metadata_item(template):
found_idx = None
for idx, item in enumerate(template):
if not isinstance(item, dict):
continue
for key in TEMPLATE_METADATA_KEYS:
if key in item:
found_idx = idx
break
if found_idx is not None:
break
metadata_item = {}
if found_idx is not None:
metadata_item = template.pop(found_idx)
return metadata_item
def _fill_schema_template_data(
template, template_data, skip_paths, required_keys=None, missing_keys=None
):
first = False
if required_keys is None:
first = True
if "skip_paths" in template_data:
skip_paths = template_data["skip_paths"]
if not isinstance(skip_paths, list):
skip_paths = [skip_paths]
# Cleanup skip paths (skip empty values)
skip_paths = [path for path in skip_paths if path]
required_keys = set()
missing_keys = set()
# Copy template data as content may change
template = copy.deepcopy(template)
# Get metadata item from template
metadata_item = _pop_metadata_item(template)
# Check for default values for template data
default_values = metadata_item.get(DEFAULT_VALUES_KEY) or {}
for key, value in default_values.items():
if key not in template_data:
template_data[key] = value
if not template:
output = template
elif isinstance(template, list):
# Store paths by first part if path
# - None value says that whole key should be skipped
skip_paths_by_first_key = {}
for path in skip_paths:
parts = path.split("/")
key = parts.pop(0)
if key not in skip_paths_by_first_key:
skip_paths_by_first_key[key] = []
value = "/".join(parts)
skip_paths_by_first_key[key].append(value or None)
output = []
for item in template:
# Get skip paths for children item
_skip_paths = []
if not isinstance(item, dict):
pass
elif item.get("type") in WRAPPER_TYPES:
_skip_paths = copy.deepcopy(skip_paths)
elif skip_paths_by_first_key:
# Check if this item should be skipped
key = item.get("key")
if key and key in skip_paths_by_first_key:
_skip_paths = skip_paths_by_first_key[key]
# Skip whole item if None is in skip paths value
if None in _skip_paths:
continue
output_item = _fill_schema_template_data(
item, template_data, _skip_paths, required_keys, missing_keys
)
if output_item:
output.append(output_item)
elif isinstance(template, dict):
output = {}
for key, value in template.items():
output[key] = _fill_schema_template_data(
value, template_data, skip_paths, required_keys, missing_keys
)
if output.get("type") in WRAPPER_TYPES and not output.get("children"):
return {}
elif isinstance(template, STRING_TYPE):
# TODO find much better way how to handle filling template data
template = template.replace("{{", "__dbcb__").replace("}}", "__decb__")
for replacement_string in template_key_pattern.findall(template):
key = str(replacement_string[1:-1])
required_keys.add(key)
if key not in template_data:
missing_keys.add(key)
continue
value = template_data[key]
if replacement_string == template:
# Replace the value with value from templates data
# - with this is possible to set value with different type
template = value
else:
# Only replace the key in string
template = template.replace(replacement_string, value)
output = template.replace("__dbcb__", "{").replace("__decb__", "}")
else:
output = template
if first and missing_keys:
raise SchemaTemplateMissingKeys(missing_keys, required_keys)
return output
def _fill_schema_template(child_data, schema_collection, schema_templates):
template_name = child_data["name"]
template = schema_templates.get(template_name)
if template is None:
if template_name in schema_collection:
raise KeyError((
"Schema \"{}\" is used as `schema_template`"
).format(template_name))
raise KeyError("Schema template \"{}\" was not found".format(
template_name
))
# Default value must be dictionary (NOT list)
# - empty list would not add any item if `template_data` are not filled
template_data = child_data.get("template_data") or {}
if isinstance(template_data, dict):
template_data = [template_data]
skip_paths = child_data.get("skip_paths") or []
if isinstance(skip_paths, STRING_TYPE):
skip_paths = [skip_paths]
output = []
for single_template_data in template_data:
try:
filled_child = _fill_schema_template_data(
template, single_template_data, skip_paths
)
except SchemaTemplateMissingKeys as exc:
raise SchemaTemplateMissingKeys(
exc.missing_keys, exc.required_keys, template_name
)
for item in filled_child:
filled_item = _fill_inner_schemas(
item, schema_collection, schema_templates
)
if filled_item["type"] == "schema_template":
output.extend(_fill_schema_template(
filled_item, schema_collection, schema_templates
))
else:
output.append(filled_item)
return output
def _fill_inner_schemas(schema_data, schema_collection, schema_templates):
if schema_data["type"] == "schema":
raise ValueError("First item in schema data can't be schema.")
children_key = "children"
object_type_key = "object_type"
for item_key in (children_key, object_type_key):
children = schema_data.get(item_key)
if not children:
continue
if object_type_key == item_key:
if not isinstance(children, dict):
continue
children = [children]
new_children = []
for child in children:
child_type = child["type"]
if child_type == "schema":
schema_name = child["name"]
if schema_name not in schema_collection:
if schema_name in schema_templates:
raise KeyError((
"Schema template \"{}\" is used as `schema`"
).format(schema_name))
raise KeyError(
"Schema \"{}\" was not found".format(schema_name)
)
filled_child = _fill_inner_schemas(
schema_collection[schema_name],
schema_collection,
schema_templates
)
elif child_type in ("template", "schema_template"):
for filled_child in _fill_schema_template(
child, schema_collection, schema_templates
):
new_children.append(filled_child)
continue
else:
filled_child = _fill_inner_schemas(
child, schema_collection, schema_templates
)
new_children.append(filled_child)
if item_key == object_type_key:
if len(new_children) != 1:
raise KeyError((
"Failed to fill object type with type: {} | name {}"
).format(
child_type, str(child.get("name"))
))
new_children = new_children[0]
schema_data[item_key] = new_children
return schema_data
# TODO reimplement logic inside entities
def validate_environment_groups_uniquenes(
schema_data, env_groups=None, keys=None
):
is_first = False
if env_groups is None:
is_first = True
env_groups = {}
keys = []
my_keys = copy.deepcopy(keys)
key = schema_data.get("key")
if key:
my_keys.append(key)
env_group_key = schema_data.get("env_group_key")
if env_group_key:
if env_group_key not in env_groups:
env_groups[env_group_key] = []
env_groups[env_group_key].append("/".join(my_keys))
children = schema_data.get("children")
if not children:
return
for child in children:
validate_environment_groups_uniquenes(
child, env_groups, copy.deepcopy(my_keys)
)
if is_first:
invalid = {}
for env_group_key, key_paths in env_groups.items():
if len(key_paths) > 1:
invalid[env_group_key] = key_paths
if invalid:
raise SchemaDuplicatedEnvGroupKeys(invalid)
def validate_schema(schema_data):
validate_environment_groups_uniquenes(schema_data)
def get_gui_schema(subfolder, main_schema_name):
dirpath = os.path.join(
os.path.dirname(__file__),
"schemas",
subfolder
)
loaded_schemas = {}
loaded_schema_templates = {}
for root, _, filenames in os.walk(dirpath):
for filename in filenames:
basename, ext = os.path.splitext(filename)
if ext != ".json":
continue
filepath = os.path.join(root, filename)
with open(filepath, "r") as json_stream:
try:
schema_data = json.load(json_stream)
except Exception as exc:
raise ValueError((
"Unable to parse JSON file {}\n{}"
).format(filepath, str(exc)))
if isinstance(schema_data, list):
loaded_schema_templates[basename] = schema_data
else:
loaded_schemas[basename] = schema_data
main_schema = _fill_inner_schemas(
loaded_schemas[main_schema_name],
loaded_schemas,
loaded_schema_templates
)
validate_schema(main_schema)
return main_schema
def get_studio_settings_schema():
return get_gui_schema("system_schema", "schema_main")
def get_project_settings_schema():
return get_gui_schema("projects_schema", "schema_main")
class OverrideStateItem:
"""Object used as item for `OverrideState` enum.
@ -426,3 +98,506 @@ class OverrideState:
DEFAULTS = OverrideStateItem(0, "Defaults")
STUDIO = OverrideStateItem(1, "Studio overrides")
PROJECT = OverrideStateItem(2, "Project Overrides")
class SchemasHub:
def __init__(self, schema_subfolder, reset=True):
self._schema_subfolder = schema_subfolder
self._loaded_types = {}
self._gui_types = tuple()
self._crashed_on_load = {}
self._loaded_templates = {}
self._loaded_schemas = {}
# It doesn't make sence to reload types on each reset as they can't be
# changed
self._load_types()
# Trigger reset
if reset:
self.reset()
def reset(self):
self._load_schemas()
@property
def gui_types(self):
return self._gui_types
def get_schema(self, schema_name):
"""Get schema definition data by it's name.
Returns:
dict: Copy of schema loaded from json files.
Raises:
KeyError: When schema name is stored in loaded templates or json
file was not possible to parse or when schema name was not
found.
"""
if schema_name not in self._loaded_schemas:
if schema_name in self._loaded_templates:
raise KeyError((
"Template \"{}\" is used as `schema`"
).format(schema_name))
elif schema_name in self._crashed_on_load:
crashed_item = self._crashed_on_load[schema_name]
raise KeyError(
"Unable to parse schema file \"{}\". {}".format(
crashed_item["filpath"], crashed_item["message"]
)
)
raise KeyError(
"Schema \"{}\" was not found".format(schema_name)
)
return copy.deepcopy(self._loaded_schemas[schema_name])
def get_template(self, template_name):
"""Get template definition data by it's name.
Returns:
list: Copy of template items loaded from json files.
Raises:
KeyError: When template name is stored in loaded schemas or json
file was not possible to parse or when template name was not
found.
"""
if template_name not in self._loaded_templates:
if template_name in self._loaded_schemas:
raise KeyError((
"Schema \"{}\" is used as `template`"
).format(template_name))
elif template_name in self._crashed_on_load:
crashed_item = self._crashed_on_load[template_name]
raise KeyError(
"Unable to parse templace file \"{}\". {}".format(
crashed_item["filpath"], crashed_item["message"]
)
)
raise KeyError(
"Template \"{}\" was not found".format(template_name)
)
return copy.deepcopy(self._loaded_templates[template_name])
def resolve_schema_data(self, schema_data):
"""Resolve single item schema data as few types can be expanded.
This is mainly for 'schema' and 'template' types. Type 'schema' does
not have entity representation and 'template' may contain more than one
output schemas.
In other cases is retuned passed schema item in list.
Goal is to have schema and template resolving at one place.
Returns:
list: Resolved schema data.
"""
schema_type = schema_data["type"]
if schema_type not in ("schema", "template", "schema_template"):
return [schema_data]
if schema_type == "schema":
return self.resolve_schema_data(
self.get_schema(schema_data["name"])
)
template_name = schema_data["name"]
template_def = self.get_template(template_name)
filled_template = self._fill_template(
schema_data, template_def
)
return filled_template
def create_schema_object(self, schema_data, *args, **kwargs):
"""Create entity for passed schema data.
Args:
schema_data(dict): Schema definition of settings entity.
Returns:
ItemEntity: Created entity for passed schema data item.
Raises:
ValueError: When 'schema', 'template' or any of wrapper types are
passed.
KeyError: When type of passed schema is not known.
"""
schema_type = schema_data["type"]
if schema_type in ("schema", "template", "schema_template"):
raise ValueError(
"Got unresolved schema data of type \"{}\"".format(schema_type)
)
if schema_type in WRAPPER_TYPES:
raise ValueError((
"Function `create_schema_object` can't create entities"
" of any wrapper type. Got type: \"{}\""
).format(schema_type))
klass = self._loaded_types.get(schema_type)
if not klass:
raise KeyError("Unknown type \"{}\"".format(schema_type))
return klass(schema_data, *args, **kwargs)
def _load_types(self):
"""Prepare entity types for cretion of their objects.
Currently all classes in `openpype.settings.entities` that inherited
from `BaseEntity` are stored as loaded types. GUI types are stored to
separated attribute to not mess up api access of entities.
TODOs:
Add more dynamic way how to add custom types from anywhere and
better handling of abstract classes. Skipping them is dangerous.
"""
from openpype.settings import entities
# Define known abstract classes
known_abstract_classes = (
entities.BaseEntity,
entities.BaseItemEntity,
entities.ItemEntity,
entities.EndpointEntity,
entities.InputEntity,
entities.BaseEnumEntity
)
self._loaded_types = {}
_gui_types = []
for attr in dir(entities):
item = getattr(entities, attr)
# Filter classes
if not inspect.isclass(item):
continue
# Skip classes that do not inherit from BaseEntity
if not issubclass(item, entities.BaseEntity):
continue
# Skip class that is abstract by design
if item in known_abstract_classes:
continue
if inspect.isabstract(item):
# Create an object to get crash and get traceback
item()
# Backwards compatibility
# Single entity may have multiple schema types
for schema_type in item.schema_types:
self._loaded_types[schema_type] = item
if item.gui_type:
_gui_types.append(item)
self._gui_types = tuple(_gui_types)
def _load_schemas(self):
"""Load schema definitions from json files."""
# Refresh all affecting variables
self._crashed_on_load = {}
self._loaded_templates = {}
self._loaded_schemas = {}
dirpath = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"schemas",
self._schema_subfolder
)
loaded_schemas = {}
loaded_templates = {}
for root, _, filenames in os.walk(dirpath):
for filename in filenames:
basename, ext = os.path.splitext(filename)
if ext != ".json":
continue
filepath = os.path.join(root, filename)
with open(filepath, "r") as json_stream:
try:
schema_data = json.load(json_stream)
except Exception as exc:
msg = str(exc)
print("Unable to parse JSON file {}\n{}".format(
filepath, msg
))
self._crashed_on_load[basename] = {
"filepath": filepath,
"message": msg
}
continue
if basename in self._crashed_on_load:
crashed_item = self._crashed_on_load[basename]
raise KeyError((
"Duplicated filename \"{}\"."
" One of them crashed on load \"{}\" {}"
).format(
filename,
crashed_item["filpath"],
crashed_item["message"]
))
if isinstance(schema_data, list):
if basename in loaded_templates:
raise KeyError(
"Duplicated template filename \"{}\"".format(
filename
)
)
loaded_templates[basename] = schema_data
else:
if basename in loaded_schemas:
raise KeyError(
"Duplicated schema filename \"{}\"".format(
filename
)
)
loaded_schemas[basename] = schema_data
self._loaded_templates = loaded_templates
self._loaded_schemas = loaded_schemas
def _fill_template(self, child_data, template_def):
"""Fill template based on schema definition and template definition.
Based on `child_data` is `template_def` modified and result is
returned.
Template definition may have defined data to fill which
should be filled with data from child data.
Child data may contain more than one output definition of an template.
Child data can define paths to skip. Path is full path of an item
which won't be returned.
TODO:
Be able to handle wrapper items here.
Args:
child_data(dict): Schema data of template item.
template_def(dict): Template definition that will be filled with
child_data.
Returns:
list: Resolved template always returns list of schemas.
"""
template_name = child_data["name"]
# Default value must be dictionary (NOT list)
# - empty list would not add any item if `template_data` are not filled
template_data = child_data.get("template_data") or {}
if isinstance(template_data, dict):
template_data = [template_data]
skip_paths = child_data.get("skip_paths") or []
if isinstance(skip_paths, STRING_TYPE):
skip_paths = [skip_paths]
output = []
for single_template_data in template_data:
try:
output.extend(self._fill_template_data(
template_def, single_template_data, skip_paths
))
except SchemaTemplateMissingKeys as exc:
raise SchemaTemplateMissingKeys(
exc.missing_keys, exc.required_keys, template_name
)
return output
def _fill_template_data(
self,
template,
template_data,
skip_paths,
required_keys=None,
missing_keys=None
):
"""Fill template values with data from schema data.
Template has more abilities than schemas. It is expected that template
will be used at multiple places (but may not). Schema represents
exactly one entity and it's children but template may represent more
entities.
Template can have "keys to fill" from their definition. Some key may be
required and some may be optional because template has their default
values defined.
Template also have ability to "skip paths" which means to skip entities
from it's content. A template can be used across multiple places with
different requirements.
Raises:
SchemaTemplateMissingKeys: When fill data do not contain all
required keys for template.
"""
first = False
if required_keys is None:
first = True
if "skip_paths" in template_data:
skip_paths = template_data["skip_paths"]
if not isinstance(skip_paths, list):
skip_paths = [skip_paths]
# Cleanup skip paths (skip empty values)
skip_paths = [path for path in skip_paths if path]
required_keys = set()
missing_keys = set()
# Copy template data as content may change
template = copy.deepcopy(template)
# Get metadata item from template
metadata_item = self._pop_metadata_item(template)
# Check for default values for template data
default_values = metadata_item.get(DEFAULT_VALUES_KEY) or {}
for key, value in default_values.items():
if key not in template_data:
template_data[key] = value
if not template:
output = template
elif isinstance(template, list):
# Store paths by first part if path
# - None value says that whole key should be skipped
skip_paths_by_first_key = {}
for path in skip_paths:
parts = path.split("/")
key = parts.pop(0)
if key not in skip_paths_by_first_key:
skip_paths_by_first_key[key] = []
value = "/".join(parts)
skip_paths_by_first_key[key].append(value or None)
output = []
for item in template:
# Get skip paths for children item
_skip_paths = []
if not isinstance(item, dict):
pass
elif item.get("type") in WRAPPER_TYPES:
_skip_paths = copy.deepcopy(skip_paths)
elif skip_paths_by_first_key:
# Check if this item should be skipped
key = item.get("key")
if key and key in skip_paths_by_first_key:
_skip_paths = skip_paths_by_first_key[key]
# Skip whole item if None is in skip paths value
if None in _skip_paths:
continue
output_item = self._fill_template_data(
item,
template_data,
_skip_paths,
required_keys,
missing_keys
)
if output_item:
output.append(output_item)
elif isinstance(template, dict):
output = {}
for key, value in template.items():
output[key] = self._fill_template_data(
value,
template_data,
skip_paths,
required_keys,
missing_keys
)
if (
output.get("type") in WRAPPER_TYPES
and not output.get("children")
):
return {}
elif isinstance(template, STRING_TYPE):
# TODO find much better way how to handle filling template data
template = (
template
.replace("{{", "__dbcb__")
.replace("}}", "__decb__")
)
full_replacement = False
for replacement_string in template_key_pattern.findall(template):
key = str(replacement_string[1:-1])
required_keys.add(key)
if key not in template_data:
missing_keys.add(key)
continue
value = template_data[key]
if replacement_string == template:
# Replace the value with value from templates data
# - with this is possible to set value with different type
template = value
full_replacement = True
else:
# Only replace the key in string
template = template.replace(replacement_string, value)
if not full_replacement:
output = (
template
.replace("__dbcb__", "{")
.replace("__decb__", "}")
)
else:
output = template
else:
output = template
if first and missing_keys:
raise SchemaTemplateMissingKeys(missing_keys, required_keys)
return output
def _pop_metadata_item(self, template_def):
"""Pop template metadata from template definition.
Template metadata may define default values if are not passed from
schema data.
"""
found_idx = None
for idx, item in enumerate(template_def):
if not isinstance(item, dict):
continue
for key in TEMPLATE_METADATA_KEYS:
if key in item:
found_idx = idx
break
if found_idx is not None:
break
metadata_item = {}
if found_idx is not None:
metadata_item = template_def.pop(found_idx)
return metadata_item

View file

@ -1,7 +1,7 @@
import os
import json
import copy
import inspect
import collections
from abc import abstractmethod
@ -10,8 +10,7 @@ from .lib import (
NOT_SET,
WRAPPER_TYPES,
OverrideState,
get_studio_settings_schema,
get_project_settings_schema
SchemasHub
)
from .exceptions import (
SchemaError,
@ -53,7 +52,12 @@ class RootEntity(BaseItemEntity):
"""
schema_types = ["root"]
def __init__(self, schema_data, reset):
def __init__(self, schema_hub, reset, main_schema_name=None):
self.schema_hub = schema_hub
if not main_schema_name:
main_schema_name = "schema_main"
schema_data = schema_hub.get_schema(main_schema_name)
super(RootEntity, self).__init__(schema_data)
self._require_restart_callbacks = []
self._item_ids_require_restart = set()
@ -130,7 +134,17 @@ class RootEntity(BaseItemEntity):
def _add_children(self, schema_data, first=True):
added_children = []
for children_schema in schema_data["children"]:
children_deque = collections.deque()
for _children_schema in schema_data["children"]:
children_schemas = self.schema_hub.resolve_schema_data(
_children_schema
)
for children_schema in children_schemas:
children_deque.append(children_schema)
while children_deque:
children_schema = children_deque.popleft()
if children_schema["type"] in WRAPPER_TYPES:
_children_schema = copy.deepcopy(children_schema)
wrapper_children = self._add_children(
@ -143,11 +157,13 @@ class RootEntity(BaseItemEntity):
child_obj = self.create_schema_object(children_schema, self)
self.children.append(child_obj)
added_children.append(child_obj)
if isinstance(child_obj, self._gui_types):
if isinstance(child_obj, self.schema_hub.gui_types):
continue
if child_obj.key in self.non_gui_children:
raise KeyError("Duplicated key \"{}\"".format(child_obj.key))
raise KeyError(
"Duplicated key \"{}\"".format(child_obj.key)
)
self.non_gui_children[child_obj.key] = child_obj
if not first:
@ -160,9 +176,6 @@ class RootEntity(BaseItemEntity):
# Store `self` to `root_item` for children entities
self.root_item = self
self._loaded_types = None
self._gui_types = None
# Children are stored by key as keys are immutable and are defined by
# schema
self.valid_value_types = (dict, )
@ -189,11 +202,10 @@ class RootEntity(BaseItemEntity):
if not KEY_REGEX.match(key):
raise InvalidKeySymbols(self.path, key)
@abstractmethod
def get_entity_from_path(self, path):
"""Return system settings entity."""
raise NotImplementedError((
"Method `get_entity_from_path` not available for \"{}\""
).format(self.__class__.__name__))
"""Return entity matching passed path."""
pass
def create_schema_object(self, schema_data, *args, **kwargs):
"""Create entity by entered schema data.
@ -201,54 +213,9 @@ class RootEntity(BaseItemEntity):
Available entities are loaded on first run. Children entities can call
this method.
"""
if self._loaded_types is None:
# Load available entities
from openpype.settings import entities
# Define known abstract classes
known_abstract_classes = (
entities.BaseEntity,
entities.BaseItemEntity,
entities.ItemEntity,
entities.EndpointEntity,
entities.InputEntity,
entities.BaseEnumEntity
)
self._loaded_types = {}
_gui_types = []
for attr in dir(entities):
item = getattr(entities, attr)
# Filter classes
if not inspect.isclass(item):
continue
# Skip classes that do not inherit from BaseEntity
if not issubclass(item, entities.BaseEntity):
continue
# Skip class that is abstract by design
if item in known_abstract_classes:
continue
if inspect.isabstract(item):
# Create an object to get crash and get traceback
item()
# Backwards compatibility
# Single entity may have multiple schema types
for schema_type in item.schema_types:
self._loaded_types[schema_type] = item
if item.gui_type:
_gui_types.append(item)
self._gui_types = tuple(_gui_types)
klass = self._loaded_types.get(schema_data["type"])
if not klass:
raise KeyError("Unknown type \"{}\"".format(schema_data["type"]))
return klass(schema_data, *args, **kwargs)
return self.schema_hub.create_schema_object(
schema_data, *args, **kwargs
)
def set_override_state(self, state):
"""Set override state and trigger it on children.
@ -491,18 +458,32 @@ class SystemSettings(RootEntity):
schema_data (dict): Pass schema data to entity. This is for development
and debugging purposes.
"""
def __init__(
self, set_studio_state=True, reset=True, schema_data=None
):
if schema_data is None:
# Load system schemas
schema_data = get_studio_settings_schema()
root_key = SYSTEM_SETTINGS_KEY
super(SystemSettings, self).__init__(schema_data, reset)
def __init__(
self, set_studio_state=True, reset=True, schema_hub=None
):
if schema_hub is None:
# Load system schemas
schema_hub = SchemasHub("system_schema")
super(SystemSettings, self).__init__(schema_hub, reset)
if set_studio_state:
self.set_studio_state()
def get_entity_from_path(self, path):
"""Return system settings entity."""
path_parts = path.split("/")
first_part = path_parts[0]
output = self
if first_part == self.root_key:
path_parts.pop(0)
for path_part in path_parts:
output = output[path_part]
return output
def _reset_values(self):
default_value = get_default_settings()[SYSTEM_SETTINGS_KEY]
for key, child_obj in self.non_gui_children.items():
@ -600,22 +581,24 @@ class ProjectSettings(RootEntity):
schema_data (dict): Pass schema data to entity. This is for development
and debugging purposes.
"""
root_key = PROJECT_SETTINGS_KEY
def __init__(
self,
project_name=None,
change_state=True,
reset=True,
schema_data=None
schema_hub=None
):
self._project_name = project_name
self._system_settings_entity = None
if schema_data is None:
if schema_hub is None:
# Load system schemas
schema_data = get_project_settings_schema()
schema_hub = SchemasHub("projects_schema")
super(ProjectSettings, self).__init__(schema_data, reset)
super(ProjectSettings, self).__init__(schema_hub, reset)
if change_state:
if self.project_name is None:

View file

@ -272,6 +272,25 @@
}
```
### hosts-enum
- enumeration of available hosts
- multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`)
- it is possible to add empty value (represented with empty string) with setting `"use_empty_value"` to `True` (Default: `False`)
- it is possible to set `"custom_labels"` for host names where key `""` is empty value (Default: `{}`)
```
{
"key": "host",
"label": "Host name",
"type": "hosts-enum",
"multiselection": false,
"use_empty_value": true,
"custom_labels": {
"": "N/A",
"nuke": "Nuke"
}
}
```
## Inputs for setting value using Pure inputs
- these inputs also have required `"key"`
- attribute `"label"` is required in few conditions

View file

@ -82,6 +82,10 @@
"type": "schema",
"name": "schema_project_hiero"
},
{
"type": "schema",
"name": "schema_project_houdini"
},
{
"type": "schema",
"name": "schema_project_blender"

View file

@ -604,6 +604,82 @@
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "dict",
"collapsible": true,
"checkbox_key": "enabled",
"key": "CollectFtrackFamily",
"label": "Collect Ftrack Family",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "list",
"collapsible": true,
"key": "profiles",
"label": "Profiles",
"use_label_wrap": true,
"object_type": {
"type": "dict",
"children": [
{
"key": "hosts",
"label": "Host names",
"type": "list",
"object_type": "text"
},
{
"key": "families",
"label": "Families",
"type": "list",
"object_type": "text"
},
{
"key": "tasks",
"label": "Task names",
"type": "list",
"object_type": "text"
},
{
"type": "separator"
},
{
"key": "add_ftrack_family",
"label": "Add Ftrack Family",
"type": "boolean"
},
{
"type": "list",
"collapsible": true,
"key": "advanced_filtering",
"label": "Advanced adding if additional families present",
"use_label_wrap": true,
"object_type": {
"type": "dict",
"children": [
{
"key": "families",
"label": "Additional Families",
"type": "list",
"object_type": "text"
},
{
"key": "add_ftrack_family",
"label": "Add Ftrack Family",
"type": "boolean"
}
]
}
}
]
}
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -25,6 +25,16 @@
}
]
},
{
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateContainers",
"label": "ValidateContainers"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -0,0 +1,27 @@
{
"type": "dict",
"collapsible": true,
"key": "houdini",
"label": "Houdini",
"is_file": true,
"children": [
{
"type": "dict",
"collapsible": true,
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateContainers",
"label": "ValidateContainers"
}
]
}
]
}
]
}

View file

@ -77,6 +77,11 @@
"type": "text",
"key": "fpath_template",
"label": "Path template"
},
{
"type": "boolean",
"key": "use_range_limit",
"label": "Use Frame range limit by default"
}
]
}

View file

@ -33,6 +33,16 @@
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateContainers",
"label": "ValidateContainers"
}
]
},
{
"type": "dict",
"collapsible": true,
@ -50,7 +60,7 @@
"object_type": "text"
}
]
}
}
]
},
{

View file

@ -59,10 +59,10 @@
"object_type": "text"
},
{
"type": "hosts-enum",
"key": "hosts",
"label": "Host names",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"type": "separator"

View file

@ -11,6 +11,25 @@
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "dict",
"collapsible": true,
"key": "ExtractSequence",
"label": "ExtractSequence",
"is_group": true,
"children": [
{
"type": "label",
"label": "<b>Review BG color</b> is used for whole scene review and for thumbnails."
},
{
"type": "color",
"key": "review_bg",
"label": "Review BG color",
"use_alpha": false
}
]
},
{
"type": "schema_template",
"name": "template_publish_plugin",

View file

@ -3,7 +3,6 @@
"key": "imageio",
"label": "Color Management and Output Formats",
"is_file": true,
"is_group": true,
"children": [
{
"key": "hiero",
@ -15,6 +14,7 @@
"type": "dict",
"label": "Workfile",
"collapsible": false,
"is_group": true,
"children": [
{
"type": "form",
@ -89,6 +89,7 @@
"type": "dict",
"label": "Colorspace on Inputs by regex detection",
"collapsible": true,
"is_group": true,
"children": [
{
"type": "list",
@ -123,6 +124,7 @@
"type": "dict",
"label": "Viewer",
"collapsible": false,
"is_group": true,
"children": [
{
"type": "text",
@ -136,6 +138,7 @@
"type": "dict",
"label": "Workfile",
"collapsible": false,
"is_group": true,
"children": [
{
"type": "form",
@ -233,6 +236,7 @@
"type": "dict",
"label": "Nodes",
"collapsible": true,
"is_group": true,
"children": [
{
"key": "requiredNodes",
@ -335,6 +339,7 @@
"type": "dict",
"label": "Colorspace on Inputs by regex detection",
"collapsible": true,
"is_group": true,
"children": [
{
"type": "list",

View file

@ -90,10 +90,10 @@
"object_type": "text"
},
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"type": "splitter"
@ -182,6 +182,16 @@
"key": "overscan_crop",
"label": "Overscan crop"
},
{
"type": "label",
"label": "Overscan color is used when input aspect ratio is not same as output aspect ratio."
},
{
"type": "color",
"label": "Overscan color",
"key": "overscan_color",
"use_alpha": false
},
{
"type": "label",
"label": "Width and Height must be both set to higher value than 0 else source resolution is used."
@ -348,10 +358,10 @@
"object_type": "text"
},
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"type": "splitter"
@ -482,10 +492,10 @@
"object_type": "text"
},
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"key": "tasks",

View file

@ -35,10 +35,10 @@
"object_type": "text"
},
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"key": "tasks",
@ -75,10 +75,10 @@
"type": "dict",
"children": [
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"type": "list",
"object_type": "text"
"multiselection": true
},
{
"key": "tasks",

View file

@ -28,7 +28,16 @@
"type": "label",
"label": "Validators"
},
{
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateContainers",
"label": "ValidateContainers"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -29,6 +29,16 @@
"type": "label",
"label": "Validators"
},
{
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateContainers",
"label": "ValidateContainers"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -29,11 +29,13 @@
"template_data": [
{
"app_variant_label": "2020",
"app_variant": "2020"
"app_variant": "2020",
"variant_skip_paths": ["use_python_2"]
},
{
"app_variant_label": "2021",
"app_variant": "2021"
"app_variant": "2021",
"variant_skip_paths": ["use_python_2"]
}
]
}

View file

@ -30,7 +30,8 @@
"children": [
{
"type": "schema_template",
"name": "template_host_variant_items"
"name": "template_host_variant_items",
"skip_paths": ["use_python_2"]
}
]
}

View file

@ -29,11 +29,13 @@
"template_data": [
{
"app_variant_label": "20",
"app_variant": "20"
"app_variant": "20",
"variant_skip_paths": ["use_python_2"]
},
{
"app_variant_label": "17",
"app_variant": "17"
"app_variant": "17",
"variant_skip_paths": ["use_python_2"]
}
]
}

View file

@ -29,11 +29,13 @@
"template_data": [
{
"app_variant_label": "2020",
"app_variant": "2020"
"app_variant": "2020",
"variant_skip_paths": ["use_python_2"]
},
{
"app_variant_label": "2021",
"app_variant": "2021"
"app_variant": "2021",
"variant_skip_paths": ["use_python_2"]
}
]
}

View file

@ -30,7 +30,8 @@
"children": [
{
"type": "schema_template",
"name": "template_host_variant_items"
"name": "template_host_variant_items",
"skip_paths": ["use_python_2"]
}
]
}

View file

@ -30,7 +30,12 @@
"children": [
{
"type": "schema_template",
"name": "template_host_variant_items"
"name": "template_host_variant_items",
"skip_paths": [
"executables",
"separator",
"arguments"
]
}
]
}

View file

@ -14,25 +14,11 @@
"roles": ["developer"]
},
{
"type": "enum",
"type": "hosts-enum",
"key": "host_name",
"label": "Host implementation",
"enum_items": [
{ "": "< without host >" },
{ "aftereffects": "aftereffects" },
{ "blender": "blender" },
{ "celaction": "celaction" },
{ "fusion": "fusion" },
{ "harmony": "harmony" },
{ "hiero": "hiero" },
{ "houdini": "houdini" },
{ "maya": "maya" },
{ "nuke": "nuke" },
{ "photoshop": "photoshop" },
{ "resolve": "resolve" },
{ "tvpaint": "tvpaint" },
{ "unreal": "unreal" }
],
"multiselection": false,
"use_empty_value": true,
"roles": ["developer"]
}
]

View file

@ -1,4 +1,9 @@
[
{
"__default_values__": {
"variant_skip_paths": null
}
},
{
"type": "dict",
"key": "{app_variant}",
@ -19,7 +24,8 @@
},
{
"type": "schema_template",
"name": "template_host_variant_items"
"name": "template_host_variant_items",
"skip_paths": "{variant_skip_paths}"
}
]
}

View file

@ -14,6 +14,7 @@
"placeholder": "Executable path"
},
{
"key": "separator",
"type":"separator"
},
{

View file

@ -532,7 +532,11 @@ def apply_local_settings_on_system_settings(system_settings, local_settings):
variants = system_settings["applications"][app_group_name]["variants"]
for app_name, app_value in value.items():
if not app_value or app_name not in variants:
if (
not app_value
or app_name not in variants
or "executables" not in variants[app_name]
):
continue
executable = app_value.get("executable")

View file

@ -15,7 +15,7 @@ provides a bridge between the file-based project inventory and configuration.
"""
import os
from Qt import QtGui
from Qt import QtGui, QtCore
from avalon.vendor import qtawesome
from openpype.api import resources
@ -23,6 +23,51 @@ ICON_CACHE = {}
NOT_FOUND = type("NotFound", (object, ), {})
class ProjectHandler(QtCore.QObject):
"""Handler of project model and current project in Launcher tool.
Helps to organize two separate widgets handling current project selection.
It is easier to trigger project change callbacks from one place than from
multiple differect places without proper handling or sequence changes.
Args:
dbcon(AvalonMongoDB): Mongo connection with Session.
model(ProjectModel): Object of projects model which is shared across
all widgets using projects. Arg dbcon should be used as source for
the model.
"""
# Project list will be refreshed each 10000 msecs
# - this is not part of helper implementation but should be used by widgets
# that may require reshing of projects
refresh_interval = 10000
# Signal emmited when project has changed
project_changed = QtCore.Signal(str)
def __init__(self, dbcon, model):
super(ProjectHandler, self).__init__()
# Store project model for usage
self.model = model
# Store dbcon
self.dbcon = dbcon
self.current_project = dbcon.Session.get("AVALON_PROJECT")
def set_project(self, project_name):
# Change current project of this handler
self.current_project = project_name
# Change session project to take effect for other widgets using the
# dbcon object.
self.dbcon.Session["AVALON_PROJECT"] = project_name
# Trigger change signal when everything is updated to new project
self.project_changed.emit(project_name)
def refresh_model(self):
self.model.refresh()
def get_action_icon(action):
icon_name = action.icon
if not icon_name:

View file

@ -325,19 +325,59 @@ class ProjectModel(QtGui.QStandardItemModel):
self.hide_invisible = False
self.project_icon = qtawesome.icon("fa.map", color="white")
self._project_names = set()
def refresh(self):
self.clear()
self.beginResetModel()
project_names = set()
for project_doc in self.get_projects():
item = QtGui.QStandardItem(self.project_icon, project_doc["name"])
self.appendRow(item)
project_names.add(project_doc["name"])
self.endResetModel()
origin_project_names = set(self._project_names)
self._project_names = project_names
project_names_to_remove = origin_project_names - project_names
if project_names_to_remove:
row_counts = {}
continuous = None
for row in range(self.rowCount()):
index = self.index(row, 0)
index_name = index.data(QtCore.Qt.DisplayRole)
if index_name in project_names_to_remove:
if continuous is None:
continuous = row
row_counts[continuous] = 0
row_counts[continuous] += 1
else:
continuous = None
for row in reversed(sorted(row_counts.keys())):
count = row_counts[row]
self.removeRows(row, count)
continuous = None
row_counts = {}
for idx, project_name in enumerate(sorted(project_names)):
if project_name in origin_project_names:
continuous = None
continue
if continuous is None:
continuous = idx
row_counts[continuous] = []
row_counts[continuous].append(project_name)
for row in reversed(sorted(row_counts.keys())):
items = []
for project_name in row_counts[row]:
item = QtGui.QStandardItem(self.project_icon, project_name)
items.append(item)
self.invisibleRootItem().insertRows(row, items)
def get_projects(self):
project_docs = []
for project_doc in sorted(
self.dbcon.projects(), key=lambda x: x["name"]
):

View file

@ -20,22 +20,15 @@ from .constants import (
class ProjectBar(QtWidgets.QWidget):
project_changed = QtCore.Signal(int)
def __init__(self, dbcon, parent=None):
def __init__(self, project_handler, parent=None):
super(ProjectBar, self).__init__(parent)
self.dbcon = dbcon
model = ProjectModel(dbcon)
model.hide_invisible = True
project_combobox = QtWidgets.QComboBox(self)
# Change delegate so stylysheets are applied
project_delegate = QtWidgets.QStyledItemDelegate(project_combobox)
project_combobox.setItemDelegate(project_delegate)
project_combobox.setModel(model)
project_combobox.setModel(project_handler.model)
project_combobox.setRootModelIndex(QtCore.QModelIndex())
layout = QtWidgets.QHBoxLayout(self)
@ -47,21 +40,43 @@ class ProjectBar(QtWidgets.QWidget):
QtWidgets.QSizePolicy.Maximum
)
self.model = model
refresh_timer = QtCore.QTimer()
refresh_timer.setInterval(project_handler.refresh_interval)
self.project_handler = project_handler
self.project_delegate = project_delegate
self.project_combobox = project_combobox
# Initialize
self.refresh()
self.refresh_timer = refresh_timer
# Signals
self.project_combobox.currentIndexChanged.connect(self.project_changed)
refresh_timer.timeout.connect(self._on_refresh_timeout)
self.project_combobox.currentIndexChanged.connect(self.on_index_change)
project_handler.project_changed.connect(self._on_project_change)
# Set current project by default if it's set.
project_name = self.dbcon.Session.get("AVALON_PROJECT")
project_name = project_handler.current_project
if project_name:
self.set_project(project_name)
def showEvent(self, event):
if not self.refresh_timer.isActive():
self.refresh_timer.start()
super(ProjectBar, self).showEvent(event)
def _on_refresh_timeout(self):
if not self.isVisible():
# Stop timer if widget is not visible
self.refresh_timer.stop()
elif self.isActiveWindow():
# Refresh projects if window is active
self.project_handler.refresh_model()
def _on_project_change(self, project_name):
if self.get_current_project() == project_name:
return
self.set_project(project_name)
def get_current_project(self):
return self.project_combobox.currentText()
@ -69,27 +84,18 @@ class ProjectBar(QtWidgets.QWidget):
index = self.project_combobox.findText(project_name)
if index < 0:
# Try refresh combobox model
self.project_combobox.blockSignals(True)
self.model.refresh()
self.project_combobox.blockSignals(False)
self.project_handler.refresh_model()
index = self.project_combobox.findText(project_name)
if index >= 0:
self.project_combobox.setCurrentIndex(index)
def refresh(self):
prev_project_name = self.get_current_project()
def on_index_change(self, idx):
if not self.isVisible():
return
# Refresh without signals
self.project_combobox.blockSignals(True)
self.model.refresh()
self.set_project(prev_project_name)
self.project_combobox.blockSignals(False)
self.project_changed.emit(self.project_combobox.currentIndex())
project_name = self.get_current_project()
self.project_handler.set_project(project_name)
class ActionBar(QtWidgets.QWidget):

View file

@ -12,7 +12,7 @@ from avalon.tools import lib as tools_lib
from avalon.tools.widgets import AssetWidget
from avalon.vendor import qtawesome
from .models import ProjectModel
from .lib import get_action_label
from .lib import get_action_label, ProjectHandler
from .widgets import (
ProjectBar,
ActionBar,
@ -89,37 +89,49 @@ class ProjectIconView(QtWidgets.QListView):
class ProjectsPanel(QtWidgets.QWidget):
"""Projects Page"""
project_clicked = QtCore.Signal(str)
def __init__(self, dbcon, parent=None):
def __init__(self, project_handler, parent=None):
super(ProjectsPanel, self).__init__(parent=parent)
layout = QtWidgets.QVBoxLayout(self)
self.dbcon = dbcon
self.dbcon.install()
view = ProjectIconView(parent=self)
view.setSelectionMode(QtWidgets.QListView.NoSelection)
flick = FlickCharm(parent=self)
flick.activateOn(view)
model = ProjectModel(self.dbcon)
model.hide_invisible = True
model.refresh()
view.setModel(model)
view.setModel(project_handler.model)
layout.addWidget(view)
refresh_timer = QtCore.QTimer()
refresh_timer.setInterval(project_handler.refresh_interval)
refresh_timer.timeout.connect(self._on_refresh_timeout)
view.clicked.connect(self.on_clicked)
self.model = model
self.view = view
self.refresh_timer = refresh_timer
self.project_handler = project_handler
def on_clicked(self, index):
if index.isValid():
project_name = index.data(QtCore.Qt.DisplayRole)
self.project_clicked.emit(project_name)
self.project_handler.set_project(project_name)
def showEvent(self, event):
self.project_handler.refresh_model()
if not self.refresh_timer.isActive():
self.refresh_timer.start()
super(ProjectsPanel, self).showEvent(event)
def _on_refresh_timeout(self):
if not self.isVisible():
# Stop timer if widget is not visible
self.refresh_timer.stop()
elif self.isActiveWindow():
# Refresh projects if window is active
self.project_handler.refresh_model()
class AssetsPanel(QtWidgets.QWidget):
@ -127,7 +139,7 @@ class AssetsPanel(QtWidgets.QWidget):
back_clicked = QtCore.Signal()
session_changed = QtCore.Signal()
def __init__(self, dbcon, parent=None):
def __init__(self, project_handler, dbcon, parent=None):
super(AssetsPanel, self).__init__(parent=parent)
self.dbcon = dbcon
@ -142,7 +154,7 @@ class AssetsPanel(QtWidgets.QWidget):
btn_back = QtWidgets.QPushButton(project_bar_widget)
btn_back.setIcon(btn_back_icon)
project_bar = ProjectBar(self.dbcon, project_bar_widget)
project_bar = ProjectBar(project_handler, project_bar_widget)
layout.addWidget(btn_back)
layout.addWidget(project_bar)
@ -185,24 +197,19 @@ class AssetsPanel(QtWidgets.QWidget):
layout.addWidget(body)
# signals
project_bar.project_changed.connect(self.on_project_changed)
project_handler.project_changed.connect(self.on_project_changed)
assets_widget.selection_changed.connect(self.on_asset_changed)
assets_widget.refreshed.connect(self.on_asset_changed)
tasks_widget.task_changed.connect(self.on_task_change)
btn_back.clicked.connect(self.back_clicked)
self.project_handler = project_handler
self.project_bar = project_bar
self.assets_widget = assets_widget
self.tasks_widget = tasks_widget
self._btn_back = btn_back
# Force initial refresh for the assets since we might not be
# trigging a Project switch if we click the project that was set
# prior to launching the Launcher
# todo: remove this behavior when AVALON_PROJECT is not required
assets_widget.refresh()
def showEvent(self, event):
super(AssetsPanel, self).showEvent(event)
@ -211,19 +218,7 @@ class AssetsPanel(QtWidgets.QWidget):
btn_size = self.project_bar.height()
self._btn_back.setFixedSize(QtCore.QSize(btn_size, btn_size))
def set_project(self, project):
before = self.project_bar.get_current_project()
if before == project:
self.assets_widget.refresh()
return
self.project_bar.set_project(project)
self.on_project_changed()
def on_project_changed(self):
project_name = self.project_bar.get_current_project()
self.dbcon.Session["AVALON_PROJECT"] = project_name
self.session_changed.emit()
self.assets_widget.refresh()
@ -232,11 +227,8 @@ class AssetsPanel(QtWidgets.QWidget):
"""Callback on asset selection changed
This updates the task view.
"""
print("Asset changed..")
asset_name = None
asset_silo = None
@ -276,6 +268,8 @@ class AssetsPanel(QtWidgets.QWidget):
class LauncherWindow(QtWidgets.QDialog):
"""Launcher interface"""
# Refresh actions each 10000msecs
actions_refresh_timeout = 10000
def __init__(self, parent=None):
super(LauncherWindow, self).__init__(parent)
@ -298,8 +292,12 @@ class LauncherWindow(QtWidgets.QDialog):
self.windowFlags() | QtCore.Qt.WindowMinimizeButtonHint
)
project_panel = ProjectsPanel(self.dbcon)
asset_panel = AssetsPanel(self.dbcon)
project_model = ProjectModel(self.dbcon)
project_model.hide_invisible = True
project_handler = ProjectHandler(self.dbcon, project_model)
project_panel = ProjectsPanel(project_handler)
asset_panel = AssetsPanel(project_handler, self.dbcon)
page_slider = SlidePageWidget()
page_slider.addWidget(project_panel)
@ -344,6 +342,12 @@ class LauncherWindow(QtWidgets.QDialog):
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
actions_refresh_timer = QtCore.QTimer()
actions_refresh_timer.setInterval(self.actions_refresh_timeout)
self.actions_refresh_timer = actions_refresh_timer
self.project_handler = project_handler
self.message_label = message_label
self.project_panel = project_panel
self.asset_panel = asset_panel
@ -353,23 +357,21 @@ class LauncherWindow(QtWidgets.QDialog):
self._page = 0
# signals
actions_refresh_timer.timeout.connect(self._on_action_timer)
actions_bar.action_clicked.connect(self.on_action_clicked)
action_history.trigger_history.connect(self.on_history_action)
project_panel.project_clicked.connect(self.on_project_clicked)
project_handler.project_changed.connect(self.on_project_change)
asset_panel.back_clicked.connect(self.on_back_clicked)
asset_panel.session_changed.connect(self.on_session_changed)
# todo: Simplify this callback connection
asset_panel.project_bar.project_changed.connect(
self.on_project_changed
)
self.resize(520, 740)
def showEvent(self, event):
super().showEvent(event)
# TODO implement refresh/reset which will trigger updating
self.discover_actions()
if not self.actions_refresh_timer.isActive():
self.actions_refresh_timer.start()
self.discover_actions()
super(LauncherWindow, self).showEvent(event)
def set_page(self, page):
current = self.page_slider.currentIndex()
@ -385,13 +387,6 @@ class LauncherWindow(QtWidgets.QDialog):
QtCore.QTimer.singleShot(5000, lambda: self.message_label.setText(""))
self.log.debug(message)
def on_project_changed(self):
project_name = self.asset_panel.project_bar.get_current_project()
self.dbcon.Session["AVALON_PROJECT"] = project_name
# Update the Action plug-ins available for the current project
self.discover_actions()
def on_session_changed(self):
self.filter_actions()
@ -402,17 +397,23 @@ class LauncherWindow(QtWidgets.QDialog):
def filter_actions(self):
self.actions_bar.filter_actions()
def on_project_clicked(self, project_name):
self.dbcon.Session["AVALON_PROJECT"] = project_name
# Refresh projects
self.asset_panel.set_project(project_name)
def _on_action_timer(self):
if not self.isVisible():
# Stop timer if widget is not visible
self.actions_refresh_timer.stop()
elif self.isActiveWindow():
# Refresh projects if window is active
self.discover_actions()
def on_project_change(self, project_name):
# Update the Action plug-ins available for the current project
self.set_page(1)
self.discover_actions()
def on_back_clicked(self):
self.dbcon.Session["AVALON_PROJECT"] = None
self.project_handler.set_project(None)
self.set_page(0)
self.project_panel.model.refresh() # Refresh projects
self.discover_actions()
def on_action_clicked(self, action):

View file

@ -121,6 +121,9 @@ class AppGroupWidget(QtWidgets.QWidget):
widgets_by_variant_name = {}
for variant_name, variant_entity in valid_variants.items():
if "executables" not in variant_entity:
continue
variant_widget = AppVariantWidget(
group_label, variant_name, variant_entity, content_widget
)
@ -193,8 +196,12 @@ class LocalApplicationsWidgets(QtWidgets.QWidget):
# Create App group specific widget and store it by the key
group_widget = AppGroupWidget(entity, self)
self.widgets_by_group_name[key] = group_widget
self.content_layout.addWidget(group_widget)
if group_widget.widgets_by_variant_name:
self.widgets_by_group_name[key] = group_widget
self.content_layout.addWidget(group_widget)
else:
group_widget.setVisible(False)
group_widget.deleteLater()
def update_local_settings(self, value):
if not value:

View file

@ -168,9 +168,6 @@ class LocalSettingsWindow(QtWidgets.QWidget):
scroll_widget = QtWidgets.QScrollArea(self)
scroll_widget.setObjectName("GroupWidget")
settings_widget = LocalSettingsWidget(scroll_widget)
scroll_widget.setWidget(settings_widget)
scroll_widget.setWidgetResizable(True)
footer = QtWidgets.QWidget(self)
@ -191,7 +188,12 @@ class LocalSettingsWindow(QtWidgets.QWidget):
save_btn.clicked.connect(self._on_save_clicked)
reset_btn.clicked.connect(self._on_reset_clicked)
self.settings_widget = settings_widget
# Do not create local settings widget in init phase as it's using
# settings objects that must be OK to be able create this widget
# - we want to show dialog if anything goes wrong
# - without reseting nothing is shown
self._settings_widget = None
self._scroll_widget = scroll_widget
self.reset_btn = reset_btn
self.save_btn = save_btn
@ -203,13 +205,53 @@ class LocalSettingsWindow(QtWidgets.QWidget):
def reset(self):
if self._reset_on_show:
self._reset_on_show = False
value = get_local_settings()
self.settings_widget.update_local_settings(value)
error_msg = None
try:
# Create settings widget if is not created yet
if self._settings_widget is None:
self._settings_widget = LocalSettingsWidget(
self._scroll_widget
)
self._scroll_widget.setWidget(self._settings_widget)
value = get_local_settings()
self._settings_widget.update_local_settings(value)
except Exception as exc:
error_msg = str(exc)
crashed = error_msg is not None
# Enable/Disable save button if crashed or not
self.save_btn.setEnabled(not crashed)
# Show/Hide settings widget if crashed or not
if self._settings_widget:
self._settings_widget.setVisible(not crashed)
if not crashed:
return
# Show message with error
title = "Something went wrong"
msg = (
"Bug: Loading of settings failed."
" Please contact your project manager or OpenPype team."
"\n\nError message:\n{}"
).format(error_msg)
dialog = QtWidgets.QMessageBox(
QtWidgets.QMessageBox.Critical,
title,
msg,
QtWidgets.QMessageBox.Ok,
self
)
dialog.exec_()
def _on_reset_clicked(self):
self.reset()
def _on_save_clicked(self):
value = self.settings_widget.settings_value()
value = self._settings_widget.settings_value()
save_local_settings(value)
self.reset()

View file

@ -1,3 +1,5 @@
import json
from Qt import QtWidgets, QtGui, QtCore
from openpype.tools.settings import CHILD_OFFSET
from .widgets import ExpandingWidget
@ -125,6 +127,117 @@ class BaseWidget(QtWidgets.QWidget):
actions_mapping[action] = remove_from_project_override
menu.addAction(action)
def _copy_value_actions(self, menu):
def copy_value():
mime_data = QtCore.QMimeData()
if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item:
entity_path = None
else:
entity_path = "/".join(
[self.entity.root_key, self.entity.path]
)
value = self.entity.value
# Copy for settings tool
settings_data = {
"root_key": self.entity.root_key,
"value": value,
"path": entity_path
}
settings_encoded_data = QtCore.QByteArray()
settings_stream = QtCore.QDataStream(
settings_encoded_data, QtCore.QIODevice.WriteOnly
)
settings_stream.writeQString(json.dumps(settings_data))
mime_data.setData(
"application/copy_settings_value", settings_encoded_data
)
# Copy as json
json_encoded_data = None
if isinstance(value, (dict, list)):
json_encoded_data = QtCore.QByteArray()
json_stream = QtCore.QDataStream(
json_encoded_data, QtCore.QIODevice.WriteOnly
)
json_stream.writeQString(json.dumps(value))
mime_data.setData("application/json", json_encoded_data)
# Copy as text
if json_encoded_data is None:
# Store value as string
mime_data.setText(str(value))
else:
# Store data as json string
mime_data.setText(json.dumps(value, indent=4))
QtWidgets.QApplication.clipboard().setMimeData(mime_data)
action = QtWidgets.QAction("Copy", menu)
return [(action, copy_value)]
def _paste_value_actions(self, menu):
output = []
# Allow paste of value only if were copied from this UI
mime_data = QtWidgets.QApplication.clipboard().mimeData()
mime_value = mime_data.data("application/copy_settings_value")
# Skip if there is nothing to do
if not mime_value:
return output
settings_stream = QtCore.QDataStream(
mime_value, QtCore.QIODevice.ReadOnly
)
mime_data_value_str = settings_stream.readQString()
mime_data_value = json.loads(mime_data_value_str)
value = mime_data_value["value"]
path = mime_data_value["path"]
root_key = mime_data_value["root_key"]
# Try to find matching entity to be able paste values to same spot
# - entity can't by dynamic or in dynamic item
# - must be in same root entity as source copy
# Can't copy system settings <-> project settings
matching_entity = None
if path and root_key == self.entity.root_key:
try:
matching_entity = self.entity.get_entity_from_path(path)
except Exception:
pass
def _set_entity_value(_entity, _value):
try:
_entity.set(_value)
except Exception:
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle("Value does not match settings schema")
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setText((
"Pasted value does not seem to match schema of destination"
" settings entity."
))
dialog.exec_()
# Simple paste value method
def paste_value():
_set_entity_value(self.entity, value)
action = QtWidgets.QAction("Paste", menu)
output.append((action, paste_value))
# Paste value to matchin entity
def paste_value_to_path():
_set_entity_value(matching_entity, value)
if matching_entity is not None:
action = QtWidgets.QAction("Paste to same place", menu)
output.append((action, paste_value_to_path))
return output
def show_actions_menu(self, event=None):
if event and event.button() != QtCore.Qt.RightButton:
return
@ -144,6 +257,15 @@ class BaseWidget(QtWidgets.QWidget):
self._add_to_project_override_action(menu, actions_mapping)
self._remove_from_project_override_action(menu, actions_mapping)
ui_actions = []
ui_actions.extend(self._copy_value_actions(menu))
ui_actions.extend(self._paste_value_actions(menu))
if ui_actions:
menu.addSeparator()
for action, callback in ui_actions:
menu.addAction(action)
actions_mapping[action] = callback
if not actions_mapping:
action = QtWidgets.QAction("< No action >")
actions_mapping[action] = None

View file

@ -183,6 +183,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
footer_widget = QtWidgets.QWidget(configurations_widget)
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
refresh_icon = qtawesome.icon("fa.refresh", color="white")
refresh_btn = QtWidgets.QPushButton(footer_widget)
refresh_btn.setIcon(refresh_icon)
footer_layout.addWidget(refresh_btn, 0)
if self.user_role == "developer":
self._add_developer_ui(footer_layout)
@ -205,8 +211,10 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
main_layout.addWidget(configurations_widget, 1)
save_btn.clicked.connect(self._save)
refresh_btn.clicked.connect(self._on_refresh)
self.save_btn = save_btn
self.refresh_btn = refresh_btn
self.require_restart_label = require_restart_label
self.scroll_widget = scroll_widget
self.content_layout = content_layout
@ -220,10 +228,6 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
return
def _add_developer_ui(self, footer_layout):
refresh_icon = qtawesome.icon("fa.refresh", color="white")
refresh_button = QtWidgets.QPushButton()
refresh_button.setIcon(refresh_icon)
modify_defaults_widget = QtWidgets.QWidget()
modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget)
modify_defaults_checkbox.setChecked(self._hide_studio_overrides)
@ -235,10 +239,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
modify_defaults_layout.addWidget(label_widget)
modify_defaults_layout.addWidget(modify_defaults_checkbox)
footer_layout.addWidget(refresh_button, 0)
footer_layout.addWidget(modify_defaults_widget, 0)
refresh_button.clicked.connect(self._on_refresh)
modify_defaults_checkbox.stateChanged.connect(
self._on_modify_defaults
)

View file

@ -25,7 +25,9 @@ class ColorWidget(InputWidget):
self._dialog.open()
return
dialog = ColorDialog(self.input_field.color(), self)
dialog = ColorDialog(
self.input_field.color(), self.entity.use_alpha, self
)
self._dialog = dialog
dialog.open()
@ -120,12 +122,12 @@ class ColorViewer(QtWidgets.QWidget):
class ColorDialog(QtWidgets.QDialog):
def __init__(self, color=None, parent=None):
def __init__(self, color=None, use_alpha=True, parent=None):
super(ColorDialog, self).__init__(parent)
self.setWindowTitle("Color picker dialog")
picker_widget = ColorPickerWidget(color, self)
picker_widget = ColorPickerWidget(color, use_alpha, self)
footer_widget = QtWidgets.QWidget(self)

View file

@ -944,10 +944,8 @@ class Window(QtWidgets.QMainWindow):
split_widget.addWidget(tasks_widget)
split_widget.addWidget(files_widget)
split_widget.addWidget(side_panel)
split_widget.setStretchFactor(0, 1)
split_widget.setStretchFactor(1, 1)
split_widget.setStretchFactor(2, 3)
split_widget.setStretchFactor(3, 1)
split_widget.setSizes([255, 160, 455, 175])
body_layout.addWidget(split_widget)
# Add top margin for tasks to align it visually with files as
@ -976,7 +974,7 @@ class Window(QtWidgets.QMainWindow):
# Force focus on the open button by default, required for Houdini.
files_widget.btn_open.setFocus()
self.resize(1000, 600)
self.resize(1200, 600)
def keyPressEvent(self, event):
"""Custom keyPressEvent.

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.1.0-nightly.3"
__version__ = "3.2.0-nightly.5"

View file

@ -4,35 +4,6 @@ from Qt import QtWidgets, QtCore, QtGui
from .color_view import draw_checkerboard_tile
slide_style = """
QSlider::groove:horizontal {
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #000, stop: 1 #fff
);
height: 8px;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1, stop:0 #ddd, stop:1 #bbb
);
border: 1px solid #777;
width: 8px;
margin-top: -1px;
margin-bottom: -1px;
border-radius: 4px;
}
QSlider::handle:horizontal:hover {
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1, stop:0 #eee, stop:1 #ddd
);
border: 1px solid #444;ff
border-radius: 4px;
}"""
class AlphaSlider(QtWidgets.QSlider):
def __init__(self, *args, **kwargs):
super(AlphaSlider, self).__init__(*args, **kwargs)
@ -80,7 +51,7 @@ class AlphaSlider(QtWidgets.QSlider):
painter.fillRect(event.rect(), QtCore.Qt.transparent)
painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform)
painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing)
rect = self.style().subControlRect(
QtWidgets.QStyle.CC_Slider,
opt,
@ -135,19 +106,8 @@ class AlphaSlider(QtWidgets.QSlider):
painter.save()
gradient = QtGui.QRadialGradient()
radius = handle_rect.height() / 2
center_x = handle_rect.width() / 2 + handle_rect.x()
center_y = handle_rect.height()
gradient.setCenter(center_x, center_y)
gradient.setCenterRadius(radius)
gradient.setFocalPoint(center_x, center_y)
gradient.setColorAt(0.9, QtGui.QColor(127, 127, 127))
gradient.setColorAt(1, QtCore.Qt.transparent)
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(gradient)
painter.setBrush(QtGui.QColor(127, 127, 127))
painter.drawEllipse(handle_rect)
painter.restore()

View file

@ -17,19 +17,12 @@ from .color_inputs import (
class ColorPickerWidget(QtWidgets.QWidget):
color_changed = QtCore.Signal(QtGui.QColor)
def __init__(self, color=None, parent=None):
def __init__(self, color=None, use_alpha=True, parent=None):
super(ColorPickerWidget, self).__init__(parent)
# Color triangle
color_triangle = QtColorTriangle(self)
alpha_slider_proxy = QtWidgets.QWidget(self)
alpha_slider = AlphaSlider(QtCore.Qt.Horizontal, alpha_slider_proxy)
alpha_slider_layout = QtWidgets.QHBoxLayout(alpha_slider_proxy)
alpha_slider_layout.setContentsMargins(5, 5, 5, 5)
alpha_slider_layout.addWidget(alpha_slider, 1)
# Eye picked widget
pick_widget = PickScreenColorWidget()
pick_widget.setMaximumHeight(50)
@ -47,8 +40,6 @@ class ColorPickerWidget(QtWidgets.QWidget):
color_view = ColorViewer(self)
color_view.setMaximumHeight(50)
alpha_inputs = AlphaInputs(self)
color_inputs_color = QtGui.QColor()
col_inputs_by_label = [
("HEX", HEXInputs(color_inputs_color, self)),
@ -58,6 +49,7 @@ class ColorPickerWidget(QtWidgets.QWidget):
]
layout = QtWidgets.QGridLayout(self)
empty_col = 1
label_col = empty_col + 1
input_col = label_col + 1
@ -65,6 +57,9 @@ class ColorPickerWidget(QtWidgets.QWidget):
empty_widget.setFixedWidth(10)
layout.addWidget(empty_widget, 0, empty_col)
layout.setColumnStretch(0, 1)
layout.setColumnStretch(input_col, 1)
row = 0
layout.addWidget(btn_pick_color, row, label_col)
layout.addWidget(color_view, row, input_col)
@ -84,20 +79,41 @@ class ColorPickerWidget(QtWidgets.QWidget):
layout.setRowStretch(row, 1)
row += 1
layout.addWidget(alpha_slider_proxy, row, 0)
alpha_label = None
alpha_slider_proxy = None
alpha_slider = None
alpha_inputs = None
if not use_alpha:
color.setAlpha(255)
else:
alpha_inputs = AlphaInputs(self)
alpha_label = QtWidgets.QLabel("Alpha", self)
alpha_slider_proxy = QtWidgets.QWidget(self)
alpha_slider = AlphaSlider(
QtCore.Qt.Horizontal, alpha_slider_proxy
)
alpha_slider_layout = QtWidgets.QHBoxLayout(alpha_slider_proxy)
alpha_slider_layout.setContentsMargins(5, 5, 5, 5)
alpha_slider_layout.addWidget(alpha_slider, 1)
layout.addWidget(alpha_slider_proxy, row, 0)
layout.addWidget(alpha_label, row, label_col)
layout.addWidget(alpha_inputs, row, input_col)
row += 1
layout.addWidget(QtWidgets.QLabel("Alpha", self), row, label_col)
layout.addWidget(alpha_inputs, row, input_col)
row += 1
layout.setRowStretch(row, 1)
color_view.set_color(color_triangle.cur_color)
color_triangle.color_changed.connect(self.triangle_color_changed)
alpha_slider.valueChanged.connect(self._on_alpha_slider_change)
pick_widget.color_selected.connect(self.on_color_change)
alpha_inputs.alpha_changed.connect(self._on_alpha_inputs_changed)
btn_pick_color.released.connect(self.pick_color)
if alpha_slider:
alpha_slider.valueChanged.connect(self._on_alpha_slider_change)
alpha_inputs.alpha_changed.connect(self._on_alpha_inputs_changed)
self.color_input_fields = color_input_fields
self.color_inputs_color = color_inputs_color
@ -131,7 +147,8 @@ class ColorPickerWidget(QtWidgets.QWidget):
return self.color_view.color()
def set_color(self, color):
self.alpha_inputs.set_alpha(color.alpha())
if self.alpha_inputs:
self.alpha_inputs.set_alpha(color.alpha())
self.on_color_change(color)
def pick_color(self):
@ -163,10 +180,10 @@ class ColorPickerWidget(QtWidgets.QWidget):
def alpha_changed(self, value):
self.color_view.set_alpha(value)
if self.alpha_slider.value() != value:
if self.alpha_slider and self.alpha_slider.value() != value:
self.alpha_slider.setValue(value)
if self.alpha_inputs.alpha_value != value:
if self.alpha_inputs and self.alpha_inputs.alpha_value != value:
self.alpha_inputs.set_alpha(value)
def _on_alpha_inputs_changed(self, value):

View file

@ -241,7 +241,11 @@ class QtColorTriangle(QtWidgets.QWidget):
# Blit the static generated background with the hue gradient onto
# the double buffer.
buf = QtGui.QImage(self.bg_image.copy())
buf = QtGui.QImage(
self.bg_image.width(),
self.bg_image.height(),
QtGui.QImage.Format_RGB32
)
# Draw the trigon
# Find the color with only the hue, and max value and saturation
@ -254,9 +258,21 @@ class QtColorTriangle(QtWidgets.QWidget):
)
# Slow step: convert the image to a pixmap
pix = QtGui.QPixmap.fromImage(buf)
pix = self.bg_image.copy()
pix_painter = QtGui.QPainter(pix)
pix_painter.setRenderHint(QtGui.QPainter.Antialiasing)
pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing)
trigon_path = QtGui.QPainterPath()
trigon_path.moveTo(self.point_a)
trigon_path.lineTo(self.point_b)
trigon_path.lineTo(self.point_c)
trigon_path.closeSubpath()
pix_painter.setClipPath(trigon_path)
pix_painter.drawImage(0, 0, buf)
pix_painter.setClipping(False)
# Draw an outline of the triangle
pix_painter.setPen(self._triangle_outline_pen)
@ -724,27 +740,37 @@ class QtColorTriangle(QtWidgets.QWidget):
lx = leftX[y]
rx = rightX[y]
# if the xdist is 0, don't draw anything.
xdist = rx - lx
if xdist == 0.0:
continue
lxi = int(floor(lx))
rxi = int(floor(rx))
rc = rightColors[y]
lc = leftColors[y]
# if the xdist is 0, don't draw anything.
xdist = rx - lx
if xdist != 0.0:
r = lc.r
g = lc.g
b = lc.b
rdelta = (rc.r - r) / xdist
gdelta = (rc.g - g) / xdist
bdelta = (rc.b - b) / xdist
r = lc.r
g = lc.g
b = lc.b
rdelta = (rc.r - r) / xdist
gdelta = (rc.g - g) / xdist
bdelta = (rc.b - b) / xdist
# Inner loop 2. Draws the line from left to right.
for x in range(lxi, rxi + 1):
buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b)))
r += rdelta
g += gdelta
b += bdelta
# Draw 2 more pixels on left side for smoothing
for x in range(lxi - 2, lxi):
buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b)))
# Inner loop 2. Draws the line from left to right.
for x in range(lxi, rxi):
buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b)))
r += rdelta
g += gdelta
b += bdelta
# Draw 2 more pixels on right side for smoothing
for x in range(rxi, rxi + 3):
buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b)))
def _radius_at(self, pos, rect):
mousexdist = pos.x() - float(rect.center().x())

View file

@ -5,6 +5,8 @@ def draw_checkerboard_tile(piece_size=None, color_1=None, color_2=None):
if piece_size is None:
piece_size = 7
# Make sure piece size is not float
piece_size = int(piece_size)
if color_1 is None:
color_1 = QtGui.QColor(188, 188, 188)

2
poetry.lock generated
View file

@ -11,7 +11,7 @@ develop = false
type = "git"
url = "https://github.com/pypeclub/acre.git"
reference = "master"
resolved_reference = "efc1b8faa8f84568538b936688ae6f7604dd194c"
resolved_reference = "68784b7eb5b7bb5f409b61ab31d4403878a3e1b7"
[[package]]
name = "aiohttp"

@ -1 +1 @@
Subproject commit efde026e5aad72dac0e69848005419e2c4f067f2
Subproject commit d8be0bdb37961e32243f1de0eb9696e86acf7443

View file

@ -92,6 +92,24 @@ def calculate_next_nightly(token="nightly"):
next_tag = last_pre_v.bump_prerelease(token=token).__str__()
return next_tag
def finalize_latest_nightly():
last_prerelease, last_pre_tag = get_last_version("CI")
last_pre_v = VersionInfo.parse(last_prerelease)
last_pre_v_finalized = last_pre_v.finalize_version()
# print(last_pre_v_finalized)
return last_pre_v_finalized.__str__()
def finalize_prerelease(prerelease):
if "/" in prerelease:
prerelease = prerelease.split("/")[-1]
prerelease_v = VersionInfo.parse(prerelease)
prerelease_v_finalized = prerelease_v.finalize_version()
return prerelease_v_finalized.__str__()
def main():
usage = "usage: %prog [options] arg"
@ -102,12 +120,22 @@ def main():
parser.add_option("-b", "--bump",
dest="bump", action="store_true",
help="Return if there is something to bump")
parser.add_option("-v", "--version",
dest="version", action="store",
help="work with explicit version")
parser.add_option("-r", "--release-latest",
dest="releaselatest", action="store_true",
help="finalize latest prerelease to a release")
parser.add_option("-p", "--prerelease",
dest="prerelease", action="store",
help="define prerelease token")
parser.add_option("-f", "--finalize",
dest="finalize", action="store",
help="define prerelease token")
parser.add_option("-v", "--version",
dest="version", action="store",
help="work with explicit version")
parser.add_option("-l", "--lastversion",
dest="lastversion", action="store",
help="work with explicit version")
(options, args) = parser.parse_args()
@ -124,6 +152,25 @@ def main():
print(next_tag_v)
bump_file_versions(next_tag_v)
if options.finalize:
new_release = finalize_prerelease(options.finalize)
print(new_release)
bump_file_versions(new_release)
if options.lastversion:
last_release, last_release_tag = get_last_version(options.lastversion)
print(last_release_tag)
if options.releaselatest:
new_release = finalize_latest_nightly()
last_release, last_release_tag = get_last_version("release")
if VersionInfo.parse(new_release) > VersionInfo.parse(last_release):
print(new_release)
bump_file_versions(new_release)
else:
print("skip")
if options.prerelease:
current_prerelease = VersionInfo.parse(options.prerelease)
new_prerelease = current_prerelease.bump_prerelease().__str__()

View file

@ -14,7 +14,7 @@ All of them are Project based, eg. each project could have different configurati
Location: Settings > Project > AfterEffects
![Harmony Project Settings](assets/admin_hosts_aftereffects_settings.png)
![AfterEffects Project Settings](assets/admin_hosts_aftereffects_settings.png)
## Publish plugins

View file

@ -0,0 +1,30 @@
---
id: admin_hosts_tvpaint
title: TVPaint
sidebar_label: TVPaint
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Subset name templates
Definition of possibile subset name templates in TVPaint integration.
### [Render Layer](artist_hosts_tvpaint#render-layer)
Render layer has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**.
- Key **render_layer** is alias for variant (user's input).
- For key **render_pass** is used predefined value `"Beauty"` (ATM value can't be changed).
### [Render pass](artist_hosts_tvpaint#render-pass)
Render pass has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**.
- Key **render_layer** is filled with value of **render_pass** from `renderLayer` group.
- Key **render_pass** is alias for variant (user's input).
:::important Render Layer/Pass templates
It is recommended to use same subset name template for both **renderLayer** and **renderPass** families.
- Example template: `"{family}{Task}_{Render_layer}_{Render_pass}"`
:::
### [Review](artist_hosts_tvpaint#review) and [Workfile](artist_hosts_tvpaint#workfile)
Families **review** and **workfile** are not manually created but are automatically generated during publishing. That's why it is recommended to not use **variant** key in their subset name template.

View file

@ -45,7 +45,7 @@ In TVPaint you can find the Tools in OpenPype menu extension. The OpenPype Tools
## Create
In TVPaint you can create and publish **[Reviews](#review)**, **[Render Passes](#render-pass)**, and **[Render Layers](#render-layer)**.
In TVPaint you can create and publish **[Reviews](#review)**, **[Workfile](#workfile)**, **[Render Passes](#render-pass)** and **[Render Layers](#render-layer)**.
You have the possibility to organize your layers by using `Color group`.
@ -67,26 +67,13 @@ OpenPype specifically never tries to guess what you want to publish from the sce
When you want to publish `review` or `render layer` or `render pass`, open the `Creator` through the Tools menu `Create` button.
### Review
### Review
`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack.
- Is automatically created during publishing.
<div class="row markdown">
<div class="col col--6 markdown">
`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack.
To create reviewable quicktime of your animation:
- select `Review` in the `Creator`
- press `Create`
- When you run [publish](#publish), file will be rendered and converted to quicktime.`
</div>
<div class="col col--6 markdown">
![createreview](assets/tvp_create_review.png)
</div>
</div>
### Workfile
`Workfile` stores the source workfile as is during publishing (e.g. for backup).
- Is automatically created during publishing.
### Render Layer

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Some files were not shown because too many files have changed in this diff Show more