Compare commits

...

765 commits

Author SHA1 Message Date
Jakub Trllo
f9bbab9944
Merge pull request #1622 from ynput/bugfix/902-ay-3875_ayon-integrate-hero-for-review
Integrate Hero: Use FileTransaction in integrate plugin
2025-12-22 13:29:11 +01:00
Jakub Trllo
826d22b166
Merge branch 'develop' into bugfix/902-ay-3875_ayon-integrate-hero-for-review 2025-12-22 13:27:09 +01:00
Jakub Trllo
b6b2726795
Merge pull request #1621 from ynput/enhancement/YN-0290_provide_source_version_description
Library: provide source version description
2025-12-19 16:21:18 +01:00
Jakub Trllo
1612b0297d fix long lines 2025-12-19 11:35:26 +01:00
Petr Kalis
a802285a6c Removed unnecessary f 2025-12-19 11:25:30 +01:00
Petr Kalis
07edce9c9c Fix missing quote 2025-12-19 11:25:08 +01:00
Petr Kalis
0dc34c32d8
Merge branch 'develop' into enhancement/YN-0290_provide_source_version_description 2025-12-19 11:23:44 +01:00
Jakub Trllo
7485d99cf6 use FileTransaction in integrate hero 2025-12-19 11:23:37 +01:00
Petr Kalis
3d0cd51e65
Updates to description format
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-19 11:22:39 +01:00
Petr Kalis
8f1eebfcbf
Refactor version parts concatenation
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-19 11:22:20 +01:00
Petr Kalis
f46f1d2e8d
Refactor description concatenation
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-19 11:21:54 +01:00
Jakub Trllo
92d4da9efa
Merge pull request #1620 from ynput/bugfix/thumbnail-safe-rescale-args
Extract thumbnail from source: Safe collection of rescale arguments
2025-12-18 17:13:33 +01:00
Petr Kalis
1be1a30b38 Always put src description on new line 2025-12-18 16:47:33 +01:00
Jakub Trllo
c55c6a2675 fix doubled line 2025-12-18 16:44:15 +01:00
Jakub Trllo
818a9f21f3 safe collection of rescale arguments 2025-12-18 16:39:52 +01:00
Petr Kalis
a83ebe3c8d Merge branch 'bugfix/thumbnail-args' into enhancement/YN-0290_provide_source_version_description 2025-12-18 16:25:09 +01:00
Petr Kalis
0b6c0f3de9 Added source version description
Links copied version to original with author information. Author is not passed on version as it might require admin privileges.
2025-12-18 16:24:06 +01:00
github-actions[bot]
4b4ccad085 chore(): update bug report / version 2025-12-18 13:13:01 +00:00
Ynbot
11f5c4ba8b [Automated] Update version in package.py for develop 2025-12-18 13:11:59 +00:00
Ynbot
ef93ab833a [Automated] Add generated package files from main 2025-12-18 13:11:14 +00:00
Jakub Trllo
9e067348bd
Merge pull request #1613 from ynput/enhancement/product-name-template-settings
Settings: Product name template profile filters
2025-12-18 12:23:45 +01:00
Jakub Trllo
69e4fb011a
Merge branch 'develop' into enhancement/product-name-template-settings 2025-12-18 12:19:41 +01:00
Jakub Trllo
46da65bf82
Merge pull request #1609 from BigRoy/chore/remove_asset_family_subset
Remove legacy usage of asset, family and subset
2025-12-18 12:04:50 +01:00
Roy Nieterau
15af2c051b
Merge branch 'develop' into enhancement/product-name-template-settings 2025-12-18 11:48:55 +01:00
Jakub Trllo
04958c3429
Merge branch 'develop' into chore/remove_asset_family_subset 2025-12-18 11:21:03 +01:00
Jakub Trllo
301b603775
Merge pull request #1616 from BigRoy/enhancement/integrate_inputlinks_no_workfile_to_debug
Integrate Input Links: Do not log warning if no workfile is present
2025-12-18 10:27:29 +01:00
Jakub Trllo
80f303c735
Merge branch 'develop' into enhancement/integrate_inputlinks_no_workfile_to_debug 2025-12-18 10:21:35 +01:00
Jakub Trllo
b22fbe3e77
Merge pull request #1614 from ynput/bugfix/thumbnail-args
Extract Thumbnail: Fix arguments
2025-12-18 10:14:01 +01:00
Roy Nieterau
b77b0583dd No workfile is allowed, and only looks scary in e.g. tray-publisher. It's not up to the Input integrator to have a strong opinion on it that it should be warning the user, so debug log level is better. 2025-12-17 23:58:30 +01:00
Jakub Trllo
bb35eccb57
Merge branch 'develop' into chore/remove_asset_family_subset 2025-12-17 15:59:18 +01:00
Jakub Trllo
4051d679dd
Merge branch 'develop' into enhancement/product-name-template-settings 2025-12-17 14:50:24 +01:00
Jakub Trllo
a9af964f4c added some typehints 2025-12-17 14:34:36 +01:00
Jakub Trllo
3fe508e773 pass thumbnail def to _create_colorspace_thumbnail 2025-12-17 14:32:11 +01:00
Jakub Trllo
9668623005
Merge pull request #1612 from ynput/enhancement/1470-yn-0067-publisher-crashed-plugins
Plugins discovery: Strict publish plugins discovery
2025-12-17 14:10:41 +01:00
Jakub Trllo
3e100408c3
Merge branch 'develop' into enhancement/1470-yn-0067-publisher-crashed-plugins 2025-12-17 14:09:09 +01:00
Jakub Trllo
e462dca889 always call '_update_footer_state' 2025-12-17 14:01:54 +01:00
Roy Nieterau
69e003c065
Merge branch 'develop' into chore/remove_asset_family_subset 2025-12-17 13:43:40 +01:00
Jakub Trllo
963e11e407
Merge branch 'develop' into enhancement/product-name-template-settings 2025-12-17 13:40:32 +01:00
Roy Nieterau
1daba76e3a
Merge pull request #1597 from BigRoy/enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-17 13:34:42 +01:00
Jakub Trllo
5982ad7944 call set_blocked only on reset 2025-12-17 12:32:19 +01:00
Jakub Trllo
de7b49e68f simplify update state 2025-12-17 12:31:49 +01:00
Roy Nieterau
2baffc253c Set min_items=1 for scope attribute in CollectUSDLayerContributions 2025-12-17 12:13:21 +01:00
Roy Nieterau
3e3cd49bea Add a warning if plug-in defaults are used 2025-12-17 11:59:48 +01:00
Jakub Trllo
108286aa34 fix refresh issue 2025-12-17 11:59:41 +01:00
Roy Nieterau
8047c70af2 Merge branch 'enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically' of https://github.com/BigRoy/ayon-core into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-17 11:58:21 +01:00
Roy Nieterau
cd1c2cdb0f Set the default value for new entries to be scoped to asset, task so that copying from older releases automatically sets it to both. This way, also newly added entries will have both by default which is better than none. 2025-12-17 11:57:29 +01:00
Jakub Trllo
6a3f28cfb8 change defaults 2025-12-17 11:19:15 +01:00
Jakub Trllo
ad36a449fd fix filter criteria for backwards compatibility 2025-12-17 11:17:10 +01:00
Jakub Trllo
67d9ec366c added product base types to product name template settings 2025-12-17 11:16:48 +01:00
Jakub Trllo
d1db95d8cb fix conversion function name 2025-12-17 11:16:03 +01:00
Jakub Trllo
e1dc93cb44 unset icon if is not blocking anymore 2025-12-17 10:55:27 +01:00
Jakub Trllo
73cc4c53b4 use correct settings 2025-12-17 10:53:43 +01:00
Jakub Trllo
f4bd5d49f9 move settings to tools 2025-12-17 10:38:48 +01:00
Mustafa Zaky Jafar
78df19df44
Merge branch 'develop' into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-17 11:35:36 +02:00
Jakub Trllo
5404153b94
Add new line character.
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-12-17 09:51:05 +01:00
Jakub Trllo
e8635725fa ruff fixes 2025-12-16 18:55:35 +01:00
Jakub Trllo
f2e014b3f8
Merge branch 'develop' into enhancement/1470-yn-0067-publisher-crashed-plugins 2025-12-16 18:44:36 +01:00
Jakub Trllo
ae7726bdef change tabs and mark blocking filepath 2025-12-16 18:31:35 +01:00
Jakub Trllo
856a58dc35 block publisher on blocking failed plugins 2025-12-16 18:03:12 +01:00
Jakub Trllo
c53a2f68e5 fix settings load 2025-12-16 18:01:10 +01:00
Jakub Trllo
096a5a809e fix imports 2025-12-16 16:28:30 +01:00
Jakub Trllo
09364a4f7e use the function in main cli publish 2025-12-16 16:20:40 +01:00
Jakub Trllo
3f72115a5e added option to filter crashed files 2025-12-16 16:12:35 +01:00
Jakub Trllo
7313025572 add return type hint 2025-12-16 12:30:53 +01:00
Jakub Trllo
b056d974f2
Merge branch 'develop' into chore/remove_asset_family_subset 2025-12-16 12:28:05 +01:00
Jakub Trllo
f7a2aa2792
Merge pull request #1610 from ynput/enhancement/remove-deprecated-cli
CLI: Remove deprecated 'extractenvironments' command
2025-12-16 12:16:18 +01:00
Jakub Trllo
d80fc97604 remove unused import 2025-12-16 12:09:38 +01:00
Jakub Trllo
0b14100976 remove line 2025-12-16 12:08:44 +01:00
Jakub Trllo
e2c9cacdd3 remove deprecated 'extractenvironments' 2025-12-16 12:02:57 +01:00
Jakub Trllo
18a4461e83 remove todo 2025-12-16 11:55:16 +01:00
Jakub Trllo
46791bc671
Merge branch 'develop' into chore/remove_asset_family_subset 2025-12-16 11:54:28 +01:00
Jakub Trllo
e32b54f911 fix 'get_versioning_start' 2025-12-16 11:53:34 +01:00
Jakub Trllo
a90eb2d54a fix burnins 2025-12-16 11:41:14 +01:00
Roy Nieterau
ea59a764cd Refactor/remove legacy usage of asset, family and subset 2025-12-16 11:19:50 +01:00
Mustafa Zaky Jafar
448d32fa42
Merge branch 'develop' into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-16 11:47:26 +02:00
Petr Kalis
0f13d7a8e1
Merge pull request #1578 from ynput/bugfix/YN-0273_big_resolution_thumbnail_ftrack
Extract thumbnail from source: Big thumbnail resolution
2025-12-16 10:46:15 +01:00
Petr Kalis
46a8db48e7
Merge branch 'develop' into bugfix/YN-0273_big_resolution_thumbnail_ftrack 2025-12-16 10:45:43 +01:00
Jakub Trllo
a88e3bab77
Merge pull request #1608 from ynput/enhancement/console-override-full-std
Console Interpreter: Override full stdout and stderr
2025-12-16 10:31:42 +01:00
Jakub Trllo
cc712739ba
Merge branch 'develop' into enhancement/console-override-full-std 2025-12-16 10:30:32 +01:00
Jakub Trllo
e03c39dce1
Merge pull request #1607 from ynput/enhancement/console-allow-name-change
Console interpreter: Allow to change registry name
2025-12-16 10:30:19 +01:00
Jakub Trllo
1614737053
Merge branch 'develop' into enhancement/console-allow-name-change 2025-12-16 10:29:43 +01:00
Roy Nieterau
74971bd3dc Cosmetics 2025-12-15 22:14:14 +01:00
Roy Nieterau
69de145bb7 Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically
# Conflicts:
#	client/ayon_core/plugins/publish/extract_usd_layer_contributions.py
2025-12-15 22:13:14 +01:00
Roy Nieterau
19f84805bd Fix scope defaults, fix order to int and enforce name to not be empty 2025-12-15 22:11:12 +01:00
Roy Nieterau
92d01a2ceb
Merge pull request #1596 from BigRoy/1595-yn-0304-usd-contributions-customizable-order-strength-per-instance 2025-12-15 21:13:47 +01:00
Roy Nieterau
34004ac538
Merge branch 'develop' into 1595-yn-0304-usd-contributions-customizable-order-strength-per-instance 2025-12-15 20:29:12 +01:00
Mustafa Zaky Jafar
362995d5f7
Merge branch 'develop' into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-15 20:01:31 +02:00
Jakub Trllo
f9de1d13ba
Merge branch 'develop' into enhancement/console-allow-name-change 2025-12-15 14:57:59 +01:00
Jakub Trllo
c86631fcf3
Merge pull request #1589 from vincentullmann/enhancement/improve_oiio_info_performance
add verbose-flag to get_oiio_info_for_input
2025-12-15 14:56:18 +01:00
Jakub Trllo
0cfc959875 swap order of kwargs 2025-12-15 14:25:51 +01:00
Jakub Trllo
a2387d1856 require kwargs 2025-12-15 14:09:27 +01:00
Jakub Trllo
5ca04b0d6e Merge branch 'develop' into enhancement/improve_oiio_info_performance
# Conflicts:
#	client/ayon_core/lib/transcoding.py
2025-12-15 14:07:39 +01:00
Jakub Trllo
bd2e26ea50 use 'verbose=False' at other places 2025-12-15 14:03:27 +01:00
Jakub Trllo
dbdc4c590b remove unused import 2025-12-15 13:38:17 +01:00
Jakub Trllo
3c0dd4335e override full stdout and stderr 2025-12-15 12:24:22 +01:00
Petr Kalis
9cb97029bf
Merge branch 'develop' into bugfix/YN-0273_big_resolution_thumbnail_ftrack 2025-12-15 12:11:23 +01:00
Petr Kalis
e3b94654f8
Updated docstrings
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-15 12:11:07 +01:00
Petr Kalis
e3fa6e446e
Updated docstring
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-15 12:10:50 +01:00
Jakub Trllo
6b58d4fba7
Merge pull request #1582 from ynput/enhancement/AY-6586_Thumbnail_presets
Enhancement: thumbnail presets
2025-12-15 12:03:30 +01:00
Jakub Trllo
6558af5ff1
Merge branch 'develop' into enhancement/AY-6586_Thumbnail_presets 2025-12-15 12:02:15 +01:00
Jakub Trllo
3dacfec4ec allow to change registry name in controller 2025-12-15 10:24:49 +01:00
Jakub Trllo
cb06323e96
Merge pull request #1601 from BigRoy/1600-collect-versions-loaded-in-scene-fails-in-unreal-due-to-lack-of-name-key-in-containers
Collect Loaded Scene Versions: skip invalid containers
2025-12-15 10:17:58 +01:00
Jakub Trllo
dbdda81f94
Merge branch 'develop' into 1600-collect-versions-loaded-in-scene-fails-in-unreal-due-to-lack-of-name-key-in-containers 2025-12-15 10:00:20 +01:00
Ondřej Samohel
527d1d6c84
Merge pull request #1605 from ynput/bugfix/change_pytest-ayon_dependency
Chore: change pytest-ayon dependency
2025-12-12 23:28:16 +01:00
Ondrej Samohel
b39e09142f
♻️ change pytest-ayon dependency 2025-12-12 23:21:38 +01:00
Roy Nieterau
e19ca9e1d1
Merge branch 'develop' into 1595-yn-0304-usd-contributions-customizable-order-strength-per-instance 2025-12-12 22:54:17 +01:00
Roy Nieterau
be2dd92a7e Merge branch 'enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically' of https://github.com/BigRoy/ayon-core into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-12 22:53:25 +01:00
Roy Nieterau
e2251ed76c
Merge branch 'develop' into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-12 22:53:16 +01:00
Roy Nieterau
c93eb31b54 Fix typo 2025-12-12 22:53:05 +01:00
Roy Nieterau
2871ecac7d Fix setting the correct order 2025-12-12 22:52:52 +01:00
Roy Nieterau
7e1720d740
Merge branch 'develop' into 1600-collect-versions-loaded-in-scene-fails-in-unreal-due-to-lack-of-name-key-in-containers 2025-12-12 22:41:35 +01:00
Roy Nieterau
6f534f4ff0
Update client/ayon_core/plugins/publish/collect_scene_loaded_versions.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-12 22:41:28 +01:00
Petr Kalis
9da077b52f
Merge branch 'develop' into enhancement/AY-6586_Thumbnail_presets 2025-12-12 16:39:39 +01:00
Petr Kalis
52e4932c97 Used renamed class name as variable 2025-12-12 16:39:22 +01:00
Jakub Trllo
7ca1a67d82
Merge pull request #1576 from ynput/enhancement/skip-base-classes
Chore: Skip base classes in plugin discovery
2025-12-12 16:20:27 +01:00
Jakub Trllo
65791a1d9f added 'skip_discovery' to loader plugin 2025-12-12 15:03:31 +01:00
Jakub Trllo
4faf61dd22 add logic description 2025-12-12 15:01:04 +01:00
Jakub Trllo
d0034b6007 use 'skip_discovery' instead 2025-12-12 15:00:51 +01:00
Jakub Trllo
2e1c9a3afb
Merge branch 'develop' into 1600-collect-versions-loaded-in-scene-fails-in-unreal-due-to-lack-of-name-key-in-containers 2025-12-12 14:13:46 +01:00
Jakub Trllo
70328e53c6
Merge branch 'develop' into enhancement/skip-base-classes 2025-12-12 12:58:08 +01:00
Jakub Trllo
7fa5b39ef6
Merge pull request #1217 from BigRoy/enhancement/transcoding_oiio_tool_for_ffmpeg_one_call
Transcoding: Use single `oiiotool` call for sequences, instead of frames one by one
2025-12-12 12:57:02 +01:00
Jakub Trllo
deb93bc95b
Merge branch 'develop' into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-12-12 12:56:30 +01:00
Jakub Trllo
237cee6593
Merge pull request #1604 from ynput/enhancement/1603-yn-0309-csv-resolution-height-and-width-on-details-panel-at-frontend
Integrate: Add resolution and pixel aspect to version attributes
2025-12-12 12:55:39 +01:00
Jakub Trllo
15b0192d4e
move step next to frames
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-12-12 12:54:17 +01:00
Jakub Trllo
ea2642ab15 added resolution and pixel aspect to version 2025-12-12 12:21:06 +01:00
Petr Kalis
8fe830f5de Merge branch 'enhancement/AY-6586_Thumbnail_presets' of https://github.com/ynput/ayon-core into enhancement/AY-6586_Thumbnail_presets 2025-12-12 11:03:01 +01:00
Petr Kalis
7329725979 Used pop
IDK why
2025-12-12 11:02:01 +01:00
Petr Kalis
7d248880cc Changed variable resolution 2025-12-12 11:01:12 +01:00
Petr Kalis
f03ae1bc15 Formatting change 2025-12-12 10:59:02 +01:00
Petr Kalis
41fa48dbe7 Formatting change 2025-12-12 10:57:19 +01:00
Petr Kalis
3dbba063ca Renamed variables 2025-12-12 10:57:02 +01:00
Petr Kalis
4d8d9078b8 Returned None
IDK why, comment, must be really important though.
2025-12-12 10:56:39 +01:00
Roy Nieterau
55eb4cccbe Add soft compatibility requirement to ayon_third_party >=1.3.0 2025-12-12 10:44:46 +01:00
Roy Nieterau
2a7316b262 Type hints to the arguments 2025-12-12 10:40:10 +01:00
Roy Nieterau
fef45cebb3 Merge branch 'enhancement/transcoding_oiio_tool_for_ffmpeg_one_call' of https://github.com/BigRoy/ayon-core into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-12-12 10:33:06 +01:00
Roy Nieterau
f9ca97ec71 Perform actual type hint 2025-12-12 10:32:54 +01:00
Roy Nieterau
af901213a2
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-12 10:31:54 +01:00
Roy Nieterau
11ecc69b35 Refactor _input -> input_item 2025-12-12 10:31:25 +01:00
Roy Nieterau
775b0724bf Merge branch 'enhancement/transcoding_oiio_tool_for_ffmpeg_one_call' of https://github.com/BigRoy/ayon-core into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-12-12 10:22:04 +01:00
Roy Nieterau
82427cb004 Fix merge conflicts 2025-12-12 10:21:45 +01:00
Roy Nieterau
197b74d1af Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call
# Conflicts:
#	client/ayon_core/plugins/publish/extract_color_transcode.py
2025-12-12 10:20:05 +01:00
Petr Kalis
ec5766f656
Merge branch 'develop' into bugfix/YN-0273_big_resolution_thumbnail_ftrack 2025-12-11 18:32:39 +01:00
Petr Kalis
8865e7a2b4 Reverting change of order
Deemed unnecessary (by Kuba)
2025-12-11 18:32:11 +01:00
Petr Kalis
6ec302d01b Merge branch 'bugfix/YN-0273_big_resolution_thumbnail_ftrack' of https://github.com/ynput/ayon-core into bugfix/YN-0273_big_resolution_thumbnail_ftrack 2025-12-11 18:26:00 +01:00
Petr Kalis
e6eaf87272 Updated titles 2025-12-11 18:25:16 +01:00
Petr Kalis
738d9cf8d8 Updated docstring 2025-12-11 18:24:09 +01:00
Petr Kalis
061e9c5015 Renamed variable 2025-12-11 18:23:17 +01:00
Petr Kalis
c1f36199c2 Renamed method 2025-12-11 18:20:07 +01:00
Petr Kalis
9f6840a18d
Formatting change
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-11 18:12:17 +01:00
Petr Kalis
bbff056268 Added psd to ExtractReview
ffmpeg and oiiotool seem to handle it fine.
2025-12-11 18:02:04 +01:00
Roy Nieterau
9ae72a1b21
Merge pull request #1599 from BigRoy/1598-publisher-create-enforces-current-task-if-no-task-is-selected-inside-dcc 2025-12-11 17:07:07 +01:00
Roy Nieterau
048cbddb43
Merge branch 'develop' into enhancement/1524-yn-0156-usd-contribution-workflow-layer-strength-configured-hierarchically 2025-12-11 15:56:21 +01:00
Roy Nieterau
b6709f9859 Also remove fallback for current folder in _get_folder_path 2025-12-11 15:36:45 +01:00
Roy Nieterau
2aaca57672
Merge branch 'develop' into 1598-publisher-create-enforces-current-task-if-no-task-is-selected-inside-dcc 2025-12-11 15:33:47 +01:00
Jakub Trllo
3c22320c43
Merge pull request #1462 from ynput/enhancement/initial-support-for-folder-in-product-name
Chore: Initial support for folder template data in product name
2025-12-11 13:52:19 +01:00
Petr Kalis
505021344b Fix logic for context thumbnail creation 2025-12-11 12:24:40 +01:00
Petr Kalis
55c74196ab Formatting changes 2025-12-11 12:20:24 +01:00
Petr Kalis
7a5d6ae77e Fix imports 2025-12-11 12:19:36 +01:00
Petr Kalis
ec510ab149
Formatting change
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-12-11 12:16:45 +01:00
Roy Nieterau
5e674844b5 Cosmetics 2025-12-10 22:19:12 +01:00
Roy Nieterau
e3206796a7 Fix #1600: Filter out containers that lack required keys (looking at you ayon-unreal!) 2025-12-10 22:16:03 +01:00
Roy Nieterau
aff0ecf436 Fix #1598: Do not fallback to current task name 2025-12-10 20:29:10 +01:00
Petr Kalis
c52a7e367b Simplified ExtractThumbnailFrom source
Removed profiles
Changed defaults for smaller resolution
2025-12-10 18:35:22 +01:00
Petr Kalis
82dd0d0a76
Merge branch 'develop' into enhancement/AY-6586_Thumbnail_presets 2025-12-10 17:33:00 +01:00
Roy Nieterau
4eece5e6e9 Allow to define department layers scoped only to a particular department layer type, e.g. "shot" versus "asset". This way, you can scope same layer names for both shot and asset at different orders if they have differing target scopes 2025-12-10 16:55:44 +01:00
Roy Nieterau
9cdecbdee0
Merge branch 'develop' into 1595-yn-0304-usd-contributions-customizable-order-strength-per-instance 2025-12-10 16:35:49 +01:00
Roy Nieterau
ced9eadd3d Use the instance attribute Strength order as the in-layer order completely so that it's the exact value, not an offset to the department layer order 2025-12-10 16:31:05 +01:00
Jakub Trllo
17b09d608b unify indentation 2025-12-10 16:03:30 +01:00
Jakub Trllo
bceb645a80
fix typo
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-12-10 15:47:14 +01:00
Jakub Trllo
d4e5f96b3b upodate overload function 2025-12-10 15:46:48 +01:00
Jakub Trllo
5462c9516a
Merge branch 'develop' into enhancement/initial-support-for-folder-in-product-name 2025-12-10 13:45:46 +01:00
Roy Nieterau
97a8b13a4e Allow specifying a strength ordering offset for each contribution to a single department layer 2025-12-10 12:56:42 +01:00
Roy Nieterau
31e6b5a139
Merge pull request #1594 from BigRoy/1593-yn-0303-thumbnail-and-review-colorspace-is-off 2025-12-10 10:47:20 +01:00
Roy Nieterau
9e34f628e6
Merge branch 'develop' into 1593-yn-0303-thumbnail-and-review-colorspace-is-off 2025-12-10 00:40:50 +01:00
Roy Nieterau
b1be956994 Also invert if target_colorspace, which means - always invert source display/view if we have any target colorspace or a display/view that differs from the source display/view 2025-12-10 00:38:49 +01:00
Roy Nieterau
f0e603fe7c Do not invert source display/view if it already matches target display/view 2025-12-10 00:30:16 +01:00
Vincent Ullmann
3d321b4896 add verbose-flag to get_oiio_info_for_input and changed oiio_color_convert to use verbose=False 2025-12-09 15:21:23 +00:00
Jakub Trllo
8103135efd
Merge pull request #1587 from ynput/bugfix/1586-yn-0299-launcher-my-tasks-view-doesnt-refresh-on-new-assignments
Tools: Update my tasks filters on refresh
2025-12-09 15:04:42 +01:00
Jakub Trllo
8076615a5f use same approach in launcher as in other tools 2025-12-09 15:02:09 +01:00
Jakub Trllo
721c1fdd8d
Merge branch 'develop' into bugfix/1586-yn-0299-launcher-my-tasks-view-doesnt-refresh-on-new-assignments 2025-12-09 14:32:40 +01:00
Jakub Trllo
9ade73fb27 refresh my tasks in all tools 2025-12-09 14:28:35 +01:00
Jakub Trllo
f3a2cad425 refresh my tasks filters on refresh 2025-12-09 14:20:28 +01:00
Jakub Trllo
de3971ed56
Merge pull request #1585 from ynput/ayon_review_integrate_timeout_retries_and_fallback_with_help
Upload files: Added simple retries to upload of review and thumbnail
2025-12-09 14:17:58 +01:00
Jakub Trllo
ab78158d6e
Fixed typo and use debug level
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-12-09 14:17:00 +01:00
Jakub Trllo
5c17102d16 remove outdated docstring info 2025-12-09 12:38:51 +01:00
Jakub Trllo
dde471332f added more logs 2025-12-09 12:33:19 +01:00
Jakub Trllo
faff50ce33 don't use custom retries if are already handled by ayon api 2025-12-09 12:03:19 +01:00
Petr Kalis
94dc9d0484 Merge remote-tracking branch 'origin/enhancement/AY-6586_Thumbnail_presets' into enhancement/AY-6586_Thumbnail_presets 2025-12-09 10:34:42 +01:00
Petr Kalis
44251c93c7 Ruff 2025-12-09 10:33:12 +01:00
Petr Kalis
8624dcce60 Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/AY-6586_Thumbnail_presets 2025-12-09 10:32:12 +01:00
Petr Kalis
a4ae90c16a
Merge branch 'develop' into enhancement/AY-6586_Thumbnail_presets 2025-12-09 10:30:27 +01:00
Jakub Trllo
647d91e288 update docstring 2025-12-08 17:26:02 +01:00
Jakub Trllo
e0597ac6de remove unnecessary imports 2025-12-08 17:20:02 +01:00
Jakub Trllo
9b35dd6cfc remove unused variable 2025-12-08 17:16:16 +01:00
Jakub Trllo
989c54001c added retries in thumbnail integration 2025-12-08 16:53:30 +01:00
Jakub Trllo
699673bbf2 slightly modified upload 2025-12-08 16:40:13 +01:00
Jakub Trllo
f0bd2b7e98 use different help file for integrate review 2025-12-08 16:39:44 +01:00
Jakub Trllo
fb2df33970 added option to define different help file 2025-12-08 16:39:13 +01:00
Petr Kalis
165f9c7e70
Merge branch 'develop' into bugfix/YN-0273_big_resolution_thumbnail_ftrack 2025-12-08 15:08:19 +01:00
Jakub Trllo
bd81f40156 Merge branch 'develop' into ayon_review_integrate_timeout_retries_and_fallback_with_help 2025-12-08 14:15:11 +01:00
Petr Kalis
00102dae85 Renamed ProfileConfig 2025-12-08 11:09:12 +01:00
Petr Kalis
ad0cbad663 Changed data types of rgb 2025-12-08 11:07:50 +01:00
Petr Kalis
cdac62aae7 Renamed hosts to host_names for ExtractThumbnailFromSource 2025-12-08 11:07:05 +01:00
Petr Kalis
14bead732c Removed unnecessary filtering
Already done in profile filter
2025-12-08 10:16:54 +01:00
Petr Kalis
f1288eb096 Renamed ProfileConfig to ThumbnailDef 2025-12-08 10:15:46 +01:00
Petr Kalis
d859ea2fc3 Explicit key values updates 2025-12-08 10:13:57 +01:00
Petr Kalis
6cfb22a4b5 Formatting change 2025-12-08 10:13:00 +01:00
Petr Kalis
a4559fe79e Changed datatype of rgb 2025-12-08 10:11:36 +01:00
Petr Kalis
89129dfeb4 Renamed hosts to host_names for ExtractThumbnail 2025-12-08 10:10:51 +01:00
Roy Nieterau
abc08e63c1
Merge pull request #1584 from ynput/bugfix/fix-kwarg-for-product-name 2025-12-06 22:08:51 +01:00
Jakub Trllo
cf28f96eda fix formatting in docstring 2025-12-05 17:49:11 +01:00
Jakub Trllo
b1db949ecc Merge branch 'develop' into enhancement/initial-support-for-folder-in-product-name
# Conflicts:
#	client/ayon_core/pipeline/create/product_name.py
2025-12-05 17:45:11 +01:00
Jakub Trllo
f665528ee7 fix 'product_name' to 'name' 2025-12-05 15:36:01 +01:00
Jakub Trllo
074c43ff68 added is_base_class to create base classes 2025-12-05 14:05:56 +01:00
Jakub Trllo
a657022919
Merge pull request #1583 from BigRoy/bugfix/fix_import_integrator
Integrator: Fix import
2025-12-05 12:25:39 +01:00
Roy Nieterau
f7f0005511 Fix import 2025-12-05 12:23:30 +01:00
Petr Kalis
32bc4248fc Typing 2025-12-05 12:11:08 +01:00
Petr Kalis
fa6e8b4478 Added missed argument 2025-12-05 12:05:54 +01:00
Petr Kalis
a59b264496 Updated Settings controlled variables 2025-12-05 11:59:50 +01:00
Petr Kalis
56df03848f Updated logging 2025-12-05 11:59:33 +01:00
Petr Kalis
c7672fd511 Fix querying of overrides 2025-12-04 18:53:30 +01:00
Jakub Trllo
523ac20121
Merge pull request #1306 from ynput/enhancement/1297-product-base-types-creation-and-creator-plugins
🏛️Product base types: Support in Creator logic
2025-12-04 18:09:28 +01:00
Jakub Trllo
24ff7f02d6
Fix wrongly resolved line 2025-12-04 18:05:42 +01:00
Jakub Trllo
aec589d9dd
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-12-04 18:03:57 +01:00
Jakub Trllo
c7e9789582
Merge pull request #1315 from ynput/enhancement/1296-product-base-types-support-in-integrator
🏛️Product base types: Support in the integrator
2025-12-04 18:03:08 +01:00
Jakub Trllo
49b736fb68
Merge branch 'develop' into enhancement/1296-product-base-types-support-in-integrator 2025-12-04 18:02:34 +01:00
Petr Kalis
c0ed22c4d7 Added profiles for ExtractThumbnail 2025-12-04 17:27:54 +01:00
Petr Kalis
7f40b6c6a2 Added conversion to profiles to ExtractThumbnail 2025-12-04 17:25:20 +01:00
Petr Kalis
2885ed1805 Added profiles to ExtractThumbnail 2025-12-04 17:24:37 +01:00
Petr Kalis
dabeb0d552 Ruff 2025-12-04 14:04:51 +01:00
Petr Kalis
a187a7fc56 Ruff 2025-12-04 13:32:14 +01:00
Petr Kalis
a426baf1a1 Typing 2025-12-04 13:30:46 +01:00
Petr Kalis
d9344239dd Fixes for resizing to actually work
Resizing argument must be before output arguments
2025-12-04 13:11:36 +01:00
Petr Kalis
bfca3175d6 Typing 2025-12-04 13:09:40 +01:00
Petr Kalis
2928c62d2b Added flag for integrate representation 2025-12-04 13:09:20 +01:00
Petr Kalis
daa9effd04 Reorganized position of context thumbnail 2025-12-04 13:08:57 +01:00
Petr Kalis
0ab00dbb4e Check for existing profile 2025-12-04 13:08:36 +01:00
Petr Kalis
ad83f76318 Introduced dataclass for config of selected profile
It makes it nicer than using dictionary
2025-12-04 13:08:11 +01:00
Petr Kalis
08c03e980b Moved order to trigger later
No need to trigger so early, but must be triggered before regular one to limit double creation of thumbnail.
2025-12-04 13:03:18 +01:00
Petr Kalis
3edb0148cd Added setting to match more from source to regular extract thumbnail 2025-12-04 13:02:23 +01:00
Petr Kalis
b39dd35af9 Removed webpublisher from regular extract_thumbnail
Must use regular one as customer uses `review` as product type. Adding `review` to generic plugin might have unforeseen consequences.
2025-12-04 13:01:49 +01:00
Jakub Trllo
74dc83d14a skip base classes 2025-12-03 17:06:19 +01:00
Jakub Trllo
a2a5f54857
Merge pull request #1570 from ynput/enhancement/1562-version-locking-does-not-work-nuke-maya-tested
Scene manager: Ignore locked containers when updating versions
2025-12-03 11:12:58 +01:00
Jakub Trllo
b25e3e27ad
Merge branch 'develop' into enhancement/1562-version-locking-does-not-work-nuke-maya-tested 2025-12-03 11:00:10 +01:00
github-actions[bot]
0ce6e70547 chore(): update bug report / version 2025-12-02 16:41:29 +00:00
Ynbot
83c4350277 [Automated] Update version in package.py for develop 2025-12-02 16:40:29 +00:00
Ynbot
7592bdcfcb [Automated] Add generated package files from main 2025-12-02 16:39:48 +00:00
Jakub Trllo
1943b897da
Merge pull request #1573 from ynput/bugfix/YN-0273_no_thumbnail_on_ftrack
Added webpublisher to extract thumbnail
2025-12-02 17:37:49 +01:00
Ondrej Samohel
acd1fcb0cf
Merge remote-tracking branch 'origin/enhancement/1296-product-base-types-support-in-integrator' into enhancement/1296-product-base-types-support-in-integrator 2025-12-02 16:01:18 +01:00
Ondrej Samohel
a9c7785700
🐶 fix linter 2025-12-02 15:59:14 +01:00
Ondřej Samohel
e413d88234
Merge branch 'develop' into enhancement/1296-product-base-types-support-in-integrator 2025-12-02 15:57:39 +01:00
Ondrej Samohel
206bcfe717
📝 add warning 2025-12-02 15:47:58 +01:00
Ondrej Samohel
1e66017861
♻️ use product base type if defined
when product base types are not supported by api, product base type should be the source of truth.
2025-12-02 15:47:39 +01:00
Ondrej Samohel
2efda3d3fe
🐛 fix import and function call/check 2025-12-02 15:41:58 +01:00
Petr Kalis
6ade0bb665 Added webpublisher to extract thumbnail for ftrack 2025-12-02 14:44:29 +01:00
Jakub Trllo
4e65d2a524
Merge pull request #1569 from ynput/enhancement/pass-host-name-to-getter-method
Chore: Pass host name to 'get_loader_action_plugin_paths'
2025-12-02 14:21:19 +01:00
Jakub Trllo
fc19076839
Merge branch 'develop' into enhancement/pass-host-name-to-getter-method 2025-12-02 14:20:45 +01:00
Jakub Trllo
2276f06733
Merge pull request #1571 from BigRoy/bugfix/yn-0264-extract-review-oiio-conversion
Extract Review: Fix layer name passed to FFMPEG after OIIO transcode
2025-12-02 14:12:43 +01:00
Roy Nieterau
c45fd481b3
Merge branch 'develop' into bugfix/yn-0264-extract-review-oiio-conversion 2025-12-02 11:01:37 +01:00
Roy Nieterau
b0c5b171c9 After OIIO transcoding force the layer name to be "" 2025-12-01 19:04:23 +01:00
Ondřej Samohel
43b557d95e
♻️ check for compatibility 2025-12-01 18:17:08 +01:00
Ondřej Samohel
85668a1b74
Merge remote-tracking branch 'origin/develop' into enhancement/1296-product-base-types-support-in-integrator 2025-12-01 16:39:35 +01:00
Jakub Trllo
055bf3fc17 ignore locked containers when updating versions 2025-12-01 16:31:36 +01:00
Jakub Trllo
cd499f4951 change IPluginPaths interface method signature 2025-12-01 15:18:16 +01:00
Jakub Trllo
215d077f31 pass host_name to 'get_loader_action_plugin_paths' 2025-12-01 13:15:19 +01:00
github-actions[bot]
31b65f22ae chore(): update bug report / version 2025-12-01 12:03:58 +00:00
Ynbot
0e34fb6474 [Automated] Update version in package.py for develop 2025-12-01 12:03:02 +00:00
Ynbot
79aa108da7 [Automated] Add generated package files from main 2025-12-01 12:02:25 +00:00
Jakub Trllo
0589733e21
Merge pull request #1541 from BigRoy/bugfix/extract_oiio_transcode_apply_scene_display_view
Fix setting display/view based on collected scene display/view if left empty in ExtractOIIOTranscode settings.
2025-12-01 12:53:55 +01:00
Jakub Trllo
930f3b3227
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-12-01 12:52:54 +01:00
Jakub Trllo
3edc31990f
Merge pull request #1472 from TobiasPharos/bugfix/1450-hardcoded-template-paths
fix hardcoded template path for "replace_with_published_scene_path"
2025-12-01 12:51:47 +01:00
Jakub Trllo
65fcdd6c07
Merge branch 'develop' into bugfix/1450-hardcoded-template-paths 2025-12-01 12:50:09 +01:00
Jakub Trllo
f1cbd3436a
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-12-01 12:39:25 +01:00
Jakub Trllo
047464dc8c
Merge pull request #1568 from ynput/enhancement/remove-python-from-dependencies
Chore: Remove python from pyproject toml
2025-12-01 12:10:38 +01:00
Jakub Trllo
58e6ab4419
Merge branch 'develop' into enhancement/remove-python-from-dependencies 2025-12-01 12:00:41 +01:00
Jakub Trllo
7f92fb0b81
Merge pull request #1565 from BigRoy/chore/remove_unused_split_cmd_args
Chore: Remove unused function: `split_cmd_args`
2025-12-01 10:58:02 +01:00
Jakub Trllo
1432d61aca
Merge branch 'develop' into chore/remove_unused_split_cmd_args 2025-12-01 10:57:18 +01:00
Jakub Trllo
9c93e6697d
Merge branch 'develop' into enhancement/remove-python-from-dependencies 2025-12-01 10:57:11 +01:00
Jakub Trllo
617887d0c3
Merge pull request #1561 from BigRoy/enhancement/allow_color_management_profile_to_disable_management
OCIO Color management: Allow profiles to also choose to disable OCIO management
2025-12-01 10:43:24 +01:00
Jakub Trllo
5a610b39b7
Merge branch 'develop' into enhancement/allow_color_management_profile_to_disable_management 2025-12-01 10:42:08 +01:00
Roy Nieterau
0b29621760
Merge pull request #1560 from BigRoy/bugfix/reshow_initialize_as_attribute
USD Contribution: Re-show 'initialize as' attribute for USD publishes
2025-12-01 10:39:19 +01:00
Roy Nieterau
2339f3f9aa
Merge branch 'develop' into bugfix/reshow_initialize_as_attribute 2025-12-01 10:38:51 +01:00
Jakub Trllo
235ba786ea
Merge branch 'develop' into chore/remove_unused_split_cmd_args 2025-12-01 10:30:34 +01:00
Jakub Trllo
b0d153ce87 remove python from pyproject toml 2025-12-01 10:30:22 +01:00
Jakub Trllo
f1fa37a431
Merge pull request #1566 from ynput/bugfix/product-name-filtering
Product name template: Fix profiles filtering
2025-12-01 10:16:23 +01:00
Roy Nieterau
930454ad08 Fix missing settings conversion 2025-11-30 21:38:36 +01:00
Jakub Trllo
7224969180 fix product name template filtering 2025-11-29 16:13:55 +01:00
Roy Nieterau
8a0e1afcb3 Remove unused function: split_cmd_args 2025-11-28 23:16:02 +01:00
Roy Nieterau
1238d8a18c
Merge branch 'develop' into enhancement/allow_color_management_profile_to_disable_management 2025-11-28 23:11:39 +01:00
Mustafa Zaky Jafar
ddb29c857b
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-11-28 17:24:50 +02:00
Jakub Ježek
151950b18c
Merge pull request #1477 from timsergeeff/bugfix/ociodisplay
using correct func for view display conversion
2025-11-28 15:50:06 +01:00
Jakub Ježek
bad910ee03
Merge branch 'develop' into bugfix/ociodisplay 2025-11-28 15:47:25 +01:00
Roy Nieterau
f5a139e61e Merge branch 'develop' of https://github.com/ynput/ayon-core into bugfix/extract_oiio_transcode_apply_scene_display_view
# Conflicts:
#	client/ayon_core/pipeline/farm/pyblish_functions.py
2025-11-28 15:46:11 +01:00
Kayla Man
e13d54bf8a
Merge pull request #1526 from ynput/enhancement/support_extract_review_for_substance_painter
Support to create Reviewables for Substance Painter
2025-11-28 21:58:23 +08:00
Kayla Man
48613ab845
Merge branch 'develop' into enhancement/support_extract_review_for_substance_painter 2025-11-28 21:58:00 +08:00
Roy Nieterau
64bfd5e132
Merge branch 'develop' into bugfix/reshow_initialize_as_attribute 2025-11-28 11:00:51 +01:00
Ondřej Samohel
07d88cd639
Merge branch 'develop' into enhancement/1296-product-base-types-support-in-integrator 2025-11-28 10:57:55 +01:00
Ondrej Samohel
feb1612200
remove argument 2025-11-27 17:18:35 +01:00
Roy Nieterau
a1bfdc94ba
Merge branch 'develop' into enhancement/allow_color_management_profile_to_disable_management 2025-11-27 16:09:12 +01:00
Roy Nieterau
61b9ce3cfa
Merge pull request #1553 from BigRoy/1291-ay-7846_extractoiiotranscode-convert-exr-to-scanline-whilst-keeping-all-channels 2025-11-27 15:37:16 +01:00
Roy Nieterau
53b84d7dcd
Merge branch 'develop' into enhancement/allow_color_management_profile_to_disable_management 2025-11-27 15:35:21 +01:00
Roy Nieterau
0b942a062f Cosmetics 2025-11-27 15:31:39 +01:00
Roy Nieterau
a7e02c19e5
Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py
Co-authored-by: Mustafa Zaky Jafar <mustafataherzaky@outlook.com>
2025-11-27 15:30:53 +01:00
Roy Nieterau
596612cc99 Move over product types in settings so order makes more sense 2025-11-27 15:02:11 +01:00
Roy Nieterau
c1210b2977 Also skip early if no representations in the data. 2025-11-27 14:42:34 +01:00
Roy Nieterau
e2727ad15e Cosmetics + type hints 2025-11-27 14:41:31 +01:00
Roy Nieterau
877a9fdecd Refactor profile hosts -> host_names 2025-11-27 14:38:35 +01:00
Roy Nieterau
1a5a6e4ad0
Merge branch 'develop' into 1291-ay-7846_extractoiiotranscode-convert-exr-to-scanline-whilst-keeping-all-channels 2025-11-27 14:21:04 +01:00
Roy Nieterau
7255ee639c
Merge pull request #1535 from BigRoy/1525-yn-0158-usd-contribution-for-shots-starting-with-digit-breaks-usd 2025-11-27 14:20:33 +01:00
Roy Nieterau
2a5210ccc5
Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py
Co-authored-by: Mustafa Zaky Jafar <mustafataherzaky@outlook.com>
2025-11-27 14:10:55 +01:00
Ondrej Samohel
bb8f214e47
🐛 fix the abstract plugin debug print 2025-11-27 11:59:20 +01:00
Ondrej Samohel
f8e8ab2b27
🐶 fix long line 2025-11-27 10:58:13 +01:00
TobiasPharos
c33795b68a get product_type from workfile instance 2025-11-27 10:56:07 +01:00
Ondrej Samohel
67364633f0
🤖 implement copilot suggestions 2025-11-27 10:56:02 +01:00
Ondrej Samohel
81fb1e73c4 handle project settings and task entity 2025-11-27 10:55:46 +01:00
TobiasPharos
fd1b3b0e64 fix for "replace_with_published_scene_path"
Published scene file has to be of productType/family "workfile"
2025-11-27 10:54:40 +01:00
Roy Nieterau
a73d8f947d Fix return value 2025-11-27 01:01:51 +01:00
Roy Nieterau
ab8a93b4a4 Cosmetics 2025-11-27 00:57:46 +01:00
Roy Nieterau
ba6a9bdca4 Allow OCIO color management profiles that have no matching profile or do match a profile with "disabled" status to be considered as NOT color managed. So that you can specify a particular part of the project to NOT be OCIO color managed. 2025-11-27 00:41:34 +01:00
Ondřej Samohel
85e5024078
Merge branch 'develop' into enhancement/1296-product-base-types-support-in-integrator 2025-11-26 18:04:02 +01:00
Ondrej Samohel
1f88b0031d
♻️ fix discovery 2025-11-26 17:57:10 +01:00
Ondrej Samohel
64f549c495
ugly thing in name of compatibility? 2025-11-26 17:48:52 +01:00
Ondrej Samohel
3a24db94f5
📝 log deprecation warning 2025-11-26 17:45:17 +01:00
Ondrej Samohel
b0005180f2
⚗️ fix tests 2025-11-26 17:40:27 +01:00
Ondrej Samohel
bb430342d8
🐶 fix linter 2025-11-26 17:35:49 +01:00
Ondrej Samohel
700b025024
♻️ move plugin check earlier, fix hints 2025-11-26 17:34:01 +01:00
Ondrej Samohel
e6007b2cee
♻️ fixes
type hints, checks
2025-11-26 17:25:10 +01:00
Ondrej Samohel
00e2e3c2ad
🎛️ fix type hints 2025-11-26 15:33:43 +01:00
Ondrej Samohel
794bb716b2
♻️ small fixes 2025-11-26 15:25:54 +01:00
Ondrej Samohel
1cddb86918
⚗️ fix tests 2025-11-26 15:17:19 +01:00
Ondrej Samohel
b967f8f818
♻️ consolidate warninings 2025-11-26 15:01:25 +01:00
Ondrej Samohel
05547c752e
♻️ remove the check for product base type support - publisher model 2025-11-26 14:27:22 +01:00
Ondrej Samohel
2cf392633e
♻️ remove unnecessary checks 2025-11-26 14:08:50 +01:00
Jakub Trllo
d6431a4990 added overload functionality 2025-11-26 12:17:13 +01:00
Jakub Trllo
0576638603
Merge branch 'develop' into enhancement/initial-support-for-folder-in-product-name 2025-11-26 11:53:01 +01:00
Roy Nieterau
4f332766f0 Use IMAGE_EXTENSIONS 2025-11-26 10:22:17 +01:00
Roy Nieterau
344f91c983 Make settings profiles more granular for OIIO post process 2025-11-26 00:38:37 +01:00
Roy Nieterau
dcb39eb912 Merge branch 'develop' of https://github.com/ynput/ayon-core into 1291-ay-7846_extractoiiotranscode-convert-exr-to-scanline-whilst-keeping-all-channels 2025-11-26 00:34:55 +01:00
Roy Nieterau
2aa7e46c9c Cosmetic type hints 2025-11-25 23:58:09 +01:00
Roy Nieterau
58432ff4dd Re-show 'initialize as' attribute for USD publish so it's clear what is going on with the initial layer. 2025-11-25 23:55:12 +01:00
Roy Nieterau
70bf746c7a Fail clearly if the path can't be resolved instead of setting path to "None" 2025-11-22 13:29:49 +01:00
Roy Nieterau
73dfff9191 Fix call to get_representation_path_by_names 2025-11-22 13:28:08 +01:00
Roy Nieterau
47247e68ac
Merge branch 'develop' into 1525-yn-0158-usd-contribution-for-shots-starting-with-digit-breaks-usd 2025-11-22 00:35:23 +01:00
Ondrej Samohel
04527b0061
♻️ change usage of product_base_types in plugins 2025-11-21 19:06:36 +01:00
Ondrej Samohel
90da1c9059
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-11-21 13:53:30 +01:00
Kayla Man
17769b5291
Merge branch 'develop' into enhancement/support_extract_review_for_substance_painter 2025-11-21 14:14:16 +08:00
github-actions[bot]
5bccc7cf2b chore(): update bug report / version 2025-11-20 14:39:23 +00:00
Ynbot
1ac26453d5 [Automated] Update version in package.py for develop 2025-11-20 14:38:01 +00:00
Ynbot
cebf3be97f [Automated] Add generated package files from main 2025-11-20 14:37:22 +00:00
Jakub Trllo
7d3a85aac0
Merge pull request #1558 from ynput/bugfix/product-types-fetching-fix
Loader tool: Fix product types fetching
2025-11-20 15:35:17 +01:00
Jakub Trllo
626f627f58 fix product types fetching in loader tool 2025-11-20 14:59:51 +01:00
Jakub Trllo
07bf997aa2
Merge branch 'develop' into 1525-yn-0158-usd-contribution-for-shots-starting-with-digit-breaks-usd 2025-11-19 16:54:53 +01:00
Jakub Trllo
3375021ee6
Merge pull request #1557 from BigRoy/enhancement/workfile_template_builder_safeguard_versionless_products
Workfile Templates: Safe-guard workfile template builder for versionless products
2025-11-19 16:50:41 +01:00
Jakub Trllo
8274cd5d82
Merge branch 'develop' into enhancement/workfile_template_builder_safeguard_versionless_products 2025-11-19 16:49:24 +01:00
Roy Nieterau
1792529267 Safe-guard workfile template builder for versionless products 2025-11-19 16:27:42 +01:00
Jakub Trllo
e90305d43f
Merge pull request #1555 from ynput/bugfix/safe-guard-ayon-url
UI Icons: Safe guard downloading of image for AYON
2025-11-19 14:04:33 +01:00
Jakub Trllo
bede14ad11
Merge branch 'develop' into bugfix/safe-guard-ayon-url 2025-11-19 14:03:32 +01:00
Jakub Trllo
1f3209698e
Merge pull request #1554 from ynput/enhancement/315-yn-0210-conditional-houdini-template-selection-based-on-asset-folder
Workfile template builder: Consider Folder in filtering
2025-11-19 12:26:29 +01:00
MustafaJafar
42da0fb424 Make Ruff Happy. 2025-11-19 13:17:12 +02:00
Mustafa Zaky Jafar
6125a7db80
Update client/ayon_core/pipeline/workfile/workfile_template_builder.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-11-19 13:15:37 +02:00
Mustafa Zaky Jafar
8ec74b80f4
Merge branch 'develop' into enhancement/315-yn-0210-conditional-houdini-template-selection-based-on-asset-folder 2025-11-19 13:04:34 +02:00
MustafaJafar
7c5a761ba5 Merge branch 'enhancement/315-yn-0210-conditional-houdini-template-selection-based-on-asset-folder' of https://github.com/ynput/ayon-core into enhancement/315-yn-0210-conditional-houdini-template-selection-based-on-asset-folder 2025-11-19 12:56:27 +02:00
MustafaJafar
17f5788e43 Template Builder: Add folder types to filter data 2025-11-19 12:56:01 +02:00
Jakub Trllo
2af5e918a7 safe guard downloading of image for ayon_url 2025-11-19 11:11:50 +01:00
Jakub Trllo
b684ba5ef0
Merge pull request #1382 from BigRoy/enhancement/collect_scene_loaded_versions_add_more_hosts
Collect Versions Loaded in Scene: Enable for more hosts
2025-11-19 10:48:27 +01:00
Jakub Trllo
0e249ae389
Merge branch 'develop' into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-11-19 10:31:35 +01:00
Mustafa Zaky Jafar
e5ae6f5547
Merge branch 'develop' into enhancement/315-yn-0210-conditional-houdini-template-selection-based-on-asset-folder 2025-11-18 22:55:45 +02:00
MustafaJafar
2375dda43b Add folderpaths for template profile filtering 2025-11-18 17:10:38 +02:00
github-actions[bot]
55f7ff6a46 chore(): update bug report / version 2025-11-18 13:06:52 +00:00
Ynbot
90eef3f6b7 [Automated] Update version in package.py for develop 2025-11-18 13:05:58 +00:00
Ynbot
3b86b36128 [Automated] Add generated package files from main 2025-11-18 13:05:19 +00:00
Jakub Trllo
d1a410c7fe
Merge pull request #1547 from ynput/enhancement/store-host-name-to-version
Publish: Store host name to version entity data
2025-11-18 12:06:29 +01:00
Jakub Trllo
89dc8502e5
Merge branch 'develop' into enhancement/store-host-name-to-version 2025-11-18 12:02:02 +01:00
Jakub Trllo
b867c76d10
Merge pull request #1545 from BigRoy/enhancement/allow_farm_instance_creation_without_colorspace
Publishing: Allow creation of farm instances without colorspace data
2025-11-18 11:09:01 +01:00
Jakub Trllo
7ba9ffc758
Merge branch 'develop' into enhancement/allow_farm_instance_creation_without_colorspace 2025-11-18 11:08:26 +01:00
Jakub Trllo
6f99cd0bef
Merge pull request #1551 from BigRoy/enhancement/context_label_remove_span
Publisher: Fix Context card being clickable in Nuke 14/15 only outside the Context label area
2025-11-17 15:03:00 +01:00
Roy Nieterau
82128c30c5 Disable text interaction instead 2025-11-17 14:55:23 +01:00
Roy Nieterau
a6ecea872e Add missing changes 2025-11-17 14:47:25 +01:00
Roy Nieterau
335f9cf21b Implement generic ExtractOIIOPostProcess plug-in.
This can be used to take any image representation through `oiiotool` to process with settings-defined arguments, to e.g. resize an image, convert all layers to scanline, etc.
2025-11-17 14:39:27 +01:00
Roy Nieterau
1c25e35777 Fix Context card being clickable in Nuke 14/15 only outside the Context label area. Previously you could only click on the far left or far right side of the context card to be able to select it and access the Context attributes.
Cosmetically the removal of the `<span>` doesn't do much to the Context card because it doesn't have a sublabel.
2025-11-17 12:18:07 +01:00
Roy Nieterau
c1b262138d Merge branch 'enhancement/collect_scene_loaded_versions_add_more_hosts' of https://github.com/BigRoy/ayon-core into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-11-16 22:42:02 +01:00
Roy Nieterau
c81d15e08e
Merge branch 'develop' into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-11-16 22:41:26 +01:00
Roy Nieterau
8be8f245d4 Merge branch 'enhancement/collect_scene_loaded_versions_add_more_hosts' of https://github.com/BigRoy/ayon-core into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-11-16 22:41:22 +01:00
Roy Nieterau
b307cc6227 Run plug-in for all hosts 2025-11-16 22:40:23 +01:00
Roy Nieterau
aea231d64e Also prefix folder name with _ for initializing the asset layer 2025-11-16 22:20:57 +01:00
Roy Nieterau
b15d1adb3c
Merge branch 'develop' into 1525-yn-0158-usd-contribution-for-shots-starting-with-digit-breaks-usd 2025-11-16 22:16:37 +01:00
Roy Nieterau
80a95c19f1 Pass on sceneDisplay (legacy colorspaceDisplay) and sceneView (legacy colorspaceView) to metadata JSON.
Also pass on `sourceDisplay` and `sourceView`
2025-11-13 23:24:08 +01:00
Roy Nieterau
3598913d43
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-11-13 22:54:18 +01:00
Roy Nieterau
62b60e8c8b
Merge branch 'develop' into enhancement/allow_farm_instance_creation_without_colorspace 2025-11-13 22:53:34 +01:00
Jakub Trllo
c3dac96dfd
Merge pull request #1548 from ynput/enhancement/1537-yn-0048-opening-documents-in-browser
Extended open file possibilities
2025-11-13 18:03:17 +01:00
Roy Nieterau
8478899b67
Apply suggestion from @BigRoy 2025-11-13 17:40:47 +01:00
Jakub Trllo
42b249a6b3 add note about caching 2025-11-13 17:32:22 +01:00
Jakub Trllo
efa702405c tune out orders 2025-11-13 17:26:55 +01:00
Jakub Trllo
46b534cfcc
merge two lines into one 2025-11-13 17:11:38 +01:00
Jakub Trllo
bab249a54a
remove debug print
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-11-13 17:11:02 +01:00
Roy Nieterau
994ba7790e
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-11-13 16:36:31 +01:00
Roy Nieterau
f29470a08c
Apply suggestion from @iLLiCiTiT
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-11-13 16:34:08 +01:00
Roy Nieterau
0262a8e763
Apply suggestion from @BigRoy 2025-11-13 16:33:00 +01:00
Jakub Trllo
84a4033606 remove unused variables 2025-11-13 15:21:14 +01:00
Jakub Trllo
3936270266 fix formatting 2025-11-13 15:16:51 +01:00
Jakub Trllo
4d90d35fc7 Extended open file possibilities 2025-11-13 15:01:08 +01:00
Jakub Trllo
2cdcfa3f22 store host name to version entity data 2025-11-13 10:49:55 +01:00
Jakub Trllo
d00e35de84
Merge pull request #1421 from ynput/enhancement/1416-loader-actions
Loader: Actions plugin
2025-11-12 18:37:32 +01:00
Jakub Trllo
e12b913cbf
Merge branch 'develop' into enhancement/1416-loader-actions 2025-11-12 18:36:15 +01:00
Jakub Trllo
463f96cda4
Merge pull request #1546 from ynput/enhancement/1177-ay-7448_product-names-filter-fields-in-settings-should-disallow-spaces
Settings: Product names template regex validation
2025-11-12 18:36:07 +01:00
Jakub Trllo
30dda67e7c Merge branch 'develop' into enhancement/1416-loader-actions
# Conflicts:
#	client/ayon_core/tools/loader/ui/window.py
2025-11-12 18:32:48 +01:00
Jakub Trllo
361f6fa30a
Merge branch 'develop' into enhancement/1177-ay-7448_product-names-filter-fields-in-settings-should-disallow-spaces 2025-11-12 18:23:01 +01:00
github-actions[bot]
2ce5ba2575 chore(): update bug report / version 2025-11-12 17:08:36 +00:00
Ynbot
ea81e643f2 [Automated] Update version in package.py for develop 2025-11-12 17:07:38 +00:00
Ynbot
26839fa5c1 [Automated] Add generated package files from main 2025-11-12 17:07:05 +00:00
Jakub Trllo
d35e09bf39
Merge branch 'develop' into enhancement/1177-ay-7448_product-names-filter-fields-in-settings-should-disallow-spaces 2025-11-12 18:05:19 +01:00
Jakub Trllo
837b36cccf
Merge pull request #1543 from ynput/enhancement/projects-fetch-force-graphql
Tools: Use GraphQl to get projects
2025-11-12 18:04:33 +01:00
Jakub Trllo
be9b476151 use better method name 2025-11-12 18:03:31 +01:00
Jakub Trllo
1cdde6d777 fix typo
Thanks @BigRoy
2025-11-12 18:03:23 +01:00
Jakub Trllo
7622c150cf fix formatting 2025-11-12 17:49:48 +01:00
Jakub Trllo
2f893574f4 change 'tasks' and 'hosts' to full attr names 2025-11-12 17:24:02 +01:00
Jakub Trllo
ca8b776ce1 added conversion function 2025-11-12 17:23:26 +01:00
Jakub Trllo
5ede9cb091 add less/greater than to allowed chars 2025-11-12 17:21:34 +01:00
Roy Nieterau
f4824cdc42 Allow creation of farm instances without colorspace data 2025-11-12 14:41:24 +01:00
Jakub Trllo
f7ea4a354b
Merge branch 'develop' into enhancement/projects-fetch-force-graphql 2025-11-12 10:37:46 +01:00
Jakub Trllo
0dc9f174d4
Merge pull request #1542 from BigRoy/enhancement/oiio_input_do_not_repeat_input_channels
ExtractOIIOTool: Avoid crash on R=Y,G=Y,B=Y usage in transcoding
2025-11-12 09:48:36 +01:00
Jakub Trllo
42642ebd34 use graphql to get projects 2025-11-12 09:41:55 +01:00
Jakub Trllo
821b55ccce
Merge branch 'develop' into enhancement/oiio_input_do_not_repeat_input_channels 2025-11-11 17:14:38 +01:00
Roy Nieterau
e2c6687690 Preserve order when making unique to avoid error on R,G,B becoming B,G,R but the channels being using in R,G,B order in --ch argument 2025-11-11 16:46:04 +01:00
Roy Nieterau
f38a6dffba Avoid repeating input channel names if e.g. R, G and B are reading from Y channel 2025-11-11 16:40:59 +01:00
github-actions[bot]
ccd54e16cc chore(): update bug report / version 2025-11-11 14:07:19 +00:00
Ynbot
8fdc943553 [Automated] Update version in package.py for develop 2025-11-11 14:05:58 +00:00
Ynbot
76cfa3e148 [Automated] Add generated package files from main 2025-11-11 14:05:22 +00:00
Jakub Trllo
d1d7bc5355
Merge pull request #1534 from marvill85/bugfix-linked-assets-regex
Linked folder regex filtering in workfile_template_builder.py
2025-11-11 15:03:01 +01:00
Jakub Trllo
90d2e341ad
Merge branch 'develop' into bugfix-linked-assets-regex 2025-11-11 14:20:44 +01:00
Jakub Trllo
7c8e7c23e9
Change code formatting 2025-11-11 14:19:04 +01:00
Mustafa Zaky Jafar
22f9e0573c
Merge branch 'develop' into bugfix/extract_oiio_transcode_apply_scene_display_view 2025-11-11 15:08:36 +02:00
Roy Nieterau
0dfaed53cb Fix setting display/view based on collected scene display/view if left empty in ExtractOIIOTranscode settings.
`instance.data["sceneDisplay"]` and `instance.data["sceneView"]` are now intended to be set to describe the user's configured display/view inside the DCC and can still be used as fallback for the `ExtractOIIOTrancode` transcoding. For the time being the legacy `colorspaceDisplay` and `colorspaceView` instance.data keys will act as fallback for backwards compatibility to represent the scene display and view.

Also see:
 https://github.com/ynput/ayon-core/issues/1430#issuecomment-3516459205
2025-11-11 12:43:00 +01:00
Jakub Trllo
9883f4bfde
Merge pull request #1539 from ynput/enhancement/1288-ay-7805_mytasks-in-loader-and-publisher
Tools: Add my tasks filters to loader and publisher
2025-11-11 11:32:26 +01:00
Jakub Trllo
dc7f155675 remove unused imports 2025-11-10 17:07:24 +01:00
Jakub Trllo
ba4ecc6f80 use filters widget in workfiles tool 2025-11-10 17:04:29 +01:00
Jakub Trllo
f9f55b48b0 added my tasks filtering to publisher 2025-11-10 16:23:19 +01:00
Jakub Trllo
3a6ee43f22 added doption to change filters 2025-11-10 16:22:20 +01:00
Jakub Trllo
9c3dec09c9 small cleanup 2025-11-10 15:53:36 +01:00
Mustafa Zaky Jafar
8947c8a6e8
Merge branch 'develop' into 1525-yn-0158-usd-contribution-for-shots-starting-with-digit-breaks-usd 2025-11-10 16:43:00 +02:00
Jakub Trllo
e6325fa2e8 use 'FoldersFiltersWidget' in launcher 2025-11-10 15:27:49 +01:00
Jakub Trllo
91d44a833b implemented my tasks filter to browser 2025-11-10 15:22:44 +01:00
Jakub Trllo
ad83d827e2 move private methods below public one 2025-11-10 15:14:32 +01:00
Jakub Trllo
0dd47211c5 add 'get_current_username' to UsersModel 2025-11-10 15:13:24 +01:00
Jakub Trllo
cef3bc229a disable case sensitivity for folders proxy 2025-11-10 15:07:12 +01:00
Jakub Trllo
d1ef11defa define helper widget for folders filtering 2025-11-10 15:06:56 +01:00
Jakub Trllo
3fcb4949f2
Merge pull request #1538 from ynput/enhancement/make)_sure_confirm_message_on_top_after_creating_hero_version
The confirm message box is always on top after creating hero version
2025-11-10 14:01:35 +01:00
Kayla Man
d5df6a99c1
Merge branch 'develop' into enhancement/make)_sure_confirm_message_on_top_after_creating_hero_version 2025-11-10 20:59:35 +08:00
Jakub Trllo
0ff3b456ce
Merge pull request #1533 from ynput/enhancement/deselect-entities-in-launcher
Launcher: Deselect entities on change of focus
2025-11-10 12:47:38 +01:00
Jakub Trllo
339d90afd7
Merge branch 'develop' into enhancement/deselect-entities-in-launcher 2025-11-10 12:46:37 +01:00
Jakub Trllo
09fa268451
Merge pull request #1224 from jm22dogs/enhancing-publisher-card-readability
Enhancing publisher card widget with folder and task name
2025-11-10 12:46:21 +01:00
Jakub Trllo
0f8339ac92 use span for context label too 2025-11-10 12:45:22 +01:00
Mustafa Zaky Jafar
9be4493a9e
remove unused import 2025-11-10 12:56:51 +02:00
Jakub Trllo
9a70ecdd7e revert the last changes 2025-11-10 11:50:16 +01:00
Jakub Trllo
6d573b6c70
Merge branch 'develop' into enhancing-publisher-card-readability 2025-11-10 11:09:10 +01:00
Jakub Trllo
770b94bde5 show context only if is not same as current context 2025-11-10 11:08:35 +01:00
Kayla Man
a07fc4bfaa make sure the confirm message box on top after creating hero version 2025-11-10 17:55:14 +08:00
Jakub Trllo
9dbd46d866 indent context a little 2025-11-10 10:46:16 +01:00
Jakub Trllo
feba551e99 remove dash between folder and task 2025-11-10 10:46:08 +01:00
Jakub Trllo
c1d0510fd3 deselect only workfiles 2025-11-10 10:05:07 +01:00
Roy Nieterau
e7896c66f3
Update server/settings/publish_plugins.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-09 11:33:33 +01:00
Roy Nieterau
113d01ce99 Add setting to always enforce the default prim value 2025-11-08 22:54:24 +01:00
Roy Nieterau
67d5422c94 If folder name starts with a digit we now prefix it with _ to avoid invalid USD data to be authored. 2025-11-08 22:45:42 +01:00
Jakub Trllo
503e627fb5 remove unused import 2025-11-07 18:05:26 +01:00
Jakub Trllo
527b1f9795 deselect entities when previous selection widget is focused 2025-11-07 17:53:57 +01:00
Jakub Trllo
ad3c4c9317
remove first dash
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-11-07 17:52:45 +01:00
marvill85
8ba1a40685
Update workfile_template_builder.py
Add optional folder_path_regex filtering to linked folder retrieval
2025-11-07 17:23:32 +01:00
Jakub Trllo
6bf7dea414
Merge pull request #1518 from FuzzkingCool/fix_missing_published_workfiles_details_in_sidepanel
Workfiles tool: Add published workfiles details
2025-11-07 16:30:35 +01:00
Jakub Trllo
f8e4b29a6c remove unused import 2025-11-07 16:16:08 +01:00
Jakub Trllo
48d2151d05 ruff fixes 2025-11-07 16:12:07 +01:00
Jakub Trllo
614ecfbc58
Merge branch 'develop' into fix_missing_published_workfiles_details_in_sidepanel 2025-11-07 16:07:12 +01:00
Jakub Trllo
0cc99003f6 separate logic for workare and publishe workfiles 2025-11-07 15:52:47 +01:00
Jakub Trllo
ece086c03f merge tho methods into one 2025-11-07 15:52:35 +01:00
Jakub Trllo
3338dbe473 selection keeps track about every value 2025-11-07 15:51:30 +01:00
Jakub Trllo
91836b99d1
Merge branch 'develop' into enhancing-publisher-card-readability 2025-11-07 14:15:54 +01:00
Jakub Trllo
ad2641264b
Merge pull request #1532 from ynput/enhancement/remove-qdesktop-usage
UIs: Remove usage of 'desktop' from codebase
2025-11-07 14:15:08 +01:00
Jakub Trllo
b87a7615e5
Merge branch 'develop' into enhancement/remove-qdesktop-usage 2025-11-07 14:14:13 +01:00
Jakub Ježek
82b4070dad
Merge pull request #1485 from ynput/bugfix/yn-0118-editorial-publishing-with-no-audio-product
Extends audio to sibling review instances under editorial publishing
2025-11-07 12:13:41 +01:00
Jakub Ježek
00c0dea2a7
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-11-07 12:12:58 +01:00
Jakub Trllo
ef2600ae5a remove unused import 2025-11-07 11:48:49 +01:00
Jakub Trllo
1cf1696108 remove usage of 'desktop' from codebase 2025-11-07 11:43:06 +01:00
Jakub Trllo
73ef8a8723
Merge branch 'develop' into enhancing-publisher-card-readability 2025-11-07 10:54:27 +01:00
Jakub Trllo
b8714b3864
faster split
Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com>
2025-11-07 10:54:15 +01:00
Jakub Trllo
6032a3332b
Merge pull request #1451 from ynput/enhancement/get-repre-path-function
Load: Get representation path signature change
2025-11-07 10:40:47 +01:00
Jakub Trllo
447c0f45e5 use cursive for task 2025-11-07 10:34:50 +01:00
Jakub Trllo
606fc39ee3 use string concatenation 2025-11-07 10:32:29 +01:00
Jakub Trllo
0f480ee410 fix updates of the label 2025-11-07 10:20:38 +01:00
Mustafa Zaky Jafar
bc54ddbc5e
Merge branch 'develop' into bugfix/ociodisplay 2025-11-07 11:16:41 +02:00
Jakub Trllo
4bf0bbe6c3
Merge branch 'develop' into enhancing-publisher-card-readability 2025-11-07 10:10:12 +01:00
Ondřej Samohel
7e13d33588
Merge branch 'develop' into enhancement/get-repre-path-function 2025-11-07 10:07:00 +01:00
Jakub Ježek
3f49ad6791
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-11-06 12:33:38 +01:00
Jakub Jezek
6ae58b4584
Refactors: Avoids errors when publishing no audio
Refactors the audio extraction process to avoid errors
when no audio instances are present in the scene.

- Prevents processing if no audio instances are found.
- Ensures correct handling of missing audio data.
- Renames temp directory variable for clarity.
2025-11-06 12:34:47 +01:00
Jakub Jezek
f22ec30e34
Refactors audio extraction for correct publishing
Updates audio extraction logic to address issues when
publishing without an audio product.

- Improves audio file handling and ensures correct
  representation assignments.
- Adds helper functions for better code organization
  and readability.
- Improves sibling instance processing.
- Fixes an issue where audio wasn't extracted correctly
  for certain cases.
2025-11-06 11:48:50 +01:00
Jakub Trllo
55a15b7d3f
Merge pull request #1271 from BigRoy/989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2
Allow review/transcoding of more channels, like "Z", "Y", "XYZ", "AR", "AG" "AB"
2025-11-06 09:38:46 +01:00
Jakub Trllo
0a67d9f511
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-11-06 09:07:41 +01:00
Jakub Trllo
2e09a7e713
Merge pull request #1531 from ynput/bugfix/1530-multi-selections-of-export-texture-sets-and-channels-fails-to-restore-for-an-instance
Publisher: Multiselection EnumDef does not loose values
2025-11-06 09:03:11 +01:00
Roy Nieterau
84db5d3965
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-11-05 23:00:22 +01:00
Jakub Jezek
e4b3aafc94
Refactors audio instance collection for clarity
Simplifies audio instance identification.

The code now uses dedicated functions to collect
and manage audio instances and their associations.
2025-11-05 17:07:53 +01:00
timsergeeff
960f3b0fb7
Merge branch 'develop' into bugfix/ociodisplay 2025-11-05 18:59:38 +03:00
Jakub Ježek
5dc462c62a
Update client/ayon_core/plugins/publish/extract_otio_audio_tracks.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-11-05 16:53:23 +01:00
Jakub Ježek
e109ff5ea8
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-11-05 16:35:25 +01:00
Jakub Trllo
2ed1d42f35 add comment to converted value 2025-11-05 16:18:59 +01:00
Jakub Trllo
026eb67e91 deepcopy value 2025-11-05 16:13:06 +01:00
Jakub Trllo
4340989039 avoid 'value' variable conflicts 2025-11-05 16:12:45 +01:00
Jakub Trllo
64f511a43b don't use set for value conversion 2025-11-05 16:12:17 +01:00
Kayla Man
e0790c1323
Merge branch 'develop' into enhancement/support_extract_review_for_substance_painter 2025-11-05 18:05:58 +08:00
Roy Nieterau
2148f8ff16
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-11-04 16:17:52 +01:00
Petr Kalis
b1422b7fb4
Merge pull request #1522 from ynput/enhancement/YN-0155_version_up_existing
Library: Version up existing product
2025-11-04 14:10:53 +01:00
Petr Kalis
89646250fc Merge branch 'enhancement/YN-0155_version_up_existing' of https://github.com/ynput/ayon-core into enhancement/YN-0155_version_up_existing 2025-11-04 14:08:25 +01:00
Petr Kalis
6b6001dc42 Refactored usage of Qt.CheckState 2025-11-04 14:07:58 +01:00
Petr Kalis
8292b612ed Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/YN-0155_version_up_existing 2025-11-04 13:55:05 +01:00
Kayla Man
c0fd2aa8c5 add additional default settings into ExtractReview for substance painter 2025-11-04 18:10:52 +08:00
Kayla Man
5ab274aa50 restore the instance data and adjust them into textureset collector in substance instead 2025-11-04 17:47:49 +08:00
Roy Nieterau
76dfbaeb68 Update docstring 2025-11-04 09:55:18 +01:00
Roy Nieterau
7648d6cc81 Fix passing on correct filename to the representation instead of including the 1001-1025%04d pattern used solely for OIIO sequence conversion 2025-11-04 09:50:50 +01:00
Roy Nieterau
87ba72eb00 Typos 2025-11-04 09:44:20 +01:00
Roy Nieterau
f8e02573c9 Merge branch 'develop' of https://github.com/ynput/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-11-04 09:16:29 +01:00
Kayla Man
cfed4afaaf use the frame range from context data if it cannot find one 2025-11-04 15:30:33 +08:00
Kayla Man
2fe89c4b46 add substance painter as host and adjust some instance data so that it can be used to review for image product 2025-11-03 22:09:25 +08:00
Petr Kalis
231205abc9
Merge branch 'develop' into enhancement/YN-0155_version_up_existing 2025-10-31 19:57:36 +01:00
Petr Kalis
51969d3bab
Merge pull request #1523 from ynput/chore/remove_private_method_call
Library: Removed unnecessary call to private method
2025-10-31 19:57:14 +01:00
Petr Kalis
9078902bf0
Merge branch 'develop' into chore/remove_private_method_call 2025-10-31 19:56:34 +01:00
Roy Nieterau
ab9703f83f Merge branch 'develop' of https://github.com/ynput/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-10-31 17:49:12 +01:00
Juan M
bacd14db7f
Merge branch 'develop' into enhancing-publisher-card-readability 2025-10-31 16:42:03 +00:00
Petr Kalis
9e6dd82c74 Removed unnecessary call to private method 2025-10-31 17:23:18 +01:00
Petr Kalis
0397ffdbc5
Merge branch 'develop' into enhancement/YN-0155_version_up_existing 2025-10-31 17:01:50 +01:00
Petr Kalis
3d9c9fe0b9
Merge pull request #1521 from ynput/enhancement/YN-0131_copy_over_product_groupping
Library: Copy over product grouping
2025-10-31 17:01:15 +01:00
Petr Kalis
7229f5d794 Reworked hardcoded version to version_up variable
1 was used as hardcoded version, this way updated will be always last version if exists.
Hardcoding 1 doesnt make sense with `get_versioning_start` which should be source of truth. Incoming value of version would make sense if we would like to start/reset specific version, which is unlikely (and currently impossible without updates to UI).
2025-10-31 16:57:50 +01:00
Petr Kalis
23b0378a0e Use public method to set private variable 2025-10-31 16:17:49 +01:00
Petr Kalis
9713852deb Unnecessary and wrong manual set 2025-10-31 16:15:19 +01:00
Petr Kalis
3bc92d88f0 Reorganized lines 2025-10-31 16:13:48 +01:00
Petr Kalis
23a6578d6f Formatting change 2025-10-31 16:09:59 +01:00
Petr Kalis
8bb4b2096a Implemented propagation of version up value 2025-10-31 16:04:54 +01:00
Petr Kalis
23fd59f23a Added version_up checkbox
If selected it creates new version for existing product, otherwise it overwrites the version.
2025-10-31 16:04:23 +01:00
Petr Kalis
43f7ace90e Make copy of selected attributes closer to existing 2025-10-31 15:58:36 +01:00
Petr Kalis
758e232b6c Copy over only limited set of attributes for safety 2025-10-31 15:35:27 +01:00
Petr Kalis
5e877f9b05 Copy over attrib to copy product groupping 2025-10-31 15:10:03 +01:00
jm22dogs
e0ffd2d948 Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancing-publisher-card-readability 2025-10-31 10:03:50 +00:00
Roy Nieterau
66c6bdd960 Do not fail on thumbnail creation if it can't resolve a reviewable channel with OIIO 2025-10-30 22:57:26 +01:00
Roy Nieterau
cb81a57ddd Allow visualizing Alpha only layers for review as a color matte 2025-10-30 22:31:49 +01:00
Roy Nieterau
ad5368eaa2 Merge branch 'develop' of https://github.com/ynput/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2
# Conflicts:
#	client/ayon_core/lib/transcoding.py
#	client/ayon_core/plugins/publish/extract_color_transcode.py
2025-10-30 20:57:29 +01:00
Mustafa Zaky Jafar
174807277a
Merge branch 'develop' into enhancement/1416-loader-actions 2025-10-30 22:08:38 +03:00
Aleks Berland
9ba9361053
Merge branch 'ynput:develop' into fix_missing_published_workfiles_details_in_sidepanel 2025-10-30 13:36:58 -04:00
Aleks Berland
13e88e70a2 Fix for missing workfiles details with "Published" filter on
- Implemented `get_published_workfile_info` and `get_published_workfile_version_comment` methods in the WorkfilesModel.
- Updated AbstractWorkfilesFrontend to define these methods as abstract.
- Enhanced BaseWorkfileController to call the new methods.
- Modified SidePanelWidget to handle published workfile context and display relevant information.
2025-10-30 13:32:26 -04:00
Jakub Ježek
87362cbc90
Merge pull request #1498 from ynput/bugfix/YN-0080-frame-range-is-showing-wrong-in-publisher
Corrects file sequence frame offset
2025-10-30 14:51:25 +01:00
Jakub Ježek
95e4195561
Merge branch 'develop' into bugfix/YN-0080-frame-range-is-showing-wrong-in-publisher 2025-10-30 14:47:32 +01:00
robin@ynput.io
9eef269aaf Add comment. 2025-10-29 16:57:49 -04:00
robin@ynput.io
b3dbee7664 Fix legacy OTIO clips detection on range remap. 2025-10-29 16:43:38 -04:00
github-actions[bot]
5cd46678b4 chore(): update bug report / version 2025-10-29 15:33:32 +00:00
Ynbot
757d42148e [Automated] Update version in package.py for develop 2025-10-29 15:32:36 +00:00
Jakub Ježek
4140af232b
Merge branch 'develop' into bugfix/YN-0080-frame-range-is-showing-wrong-in-publisher 2025-10-24 09:26:48 +02:00
Jakub Ježek
d9b8feec01
Merge branch 'develop' into bugfix/YN-0080-frame-range-is-showing-wrong-in-publisher 2025-10-22 16:58:36 +02:00
Jakub Trllo
e0f3a6f5d9
Merge branch 'develop' into enhancement/1416-loader-actions 2025-10-22 16:21:18 +02:00
Jakub Trllo
90fe64303d
Merge branch 'develop' into enhancement/get-repre-path-function 2025-10-22 16:19:58 +02:00
Jakub Jezek
90852663d1
ruff improvements 2025-10-21 15:49:15 +02:00
Jakub Jezek
d2fdae67e7
Adds audio instance attribute collection
Adds a collector to identify audio instances and
link them to sibling instances.

This ensures that sibling instances, requiring audio
for reviewable media, inherit audio attributes.

The collector checks and links audio if:
- The sibling instance shares the same parent ID.
- The instance is not the audio instance itself.
2025-10-21 15:47:11 +02:00
Jakub Jezek
311fab2ab1
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-10-21 15:37:25 +02:00
Jakub Jezek
182e457505
Improve logic for checking already existing audio key 2025-10-21 15:35:12 +02:00
Jakub Jezek
34b292b06a
revert audio collector changes 2025-10-21 15:33:55 +02:00
Jakub Ježek
f1043acf46
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-10-21 15:09:31 +02:00
Jakub Jezek
0d49f5a8df
Fixes: Corrects file sequence frame offset
Corrects the calculation of the frame offset
for file sequences in editorial workflows.

- Ensures accurate frame mapping.
- Resolves issues with incorrect frame ranges.
2025-10-21 11:14:10 +02:00
Jakub Ježek
5a51c2b578
Merge branch 'develop' into bugfix/yn-0118-editorial-publishing-with-no-audio-product 2025-10-21 10:25:10 +02:00
Jakub Trllo
d7433f84d7 use setattr 2025-10-20 14:58:34 +02:00
Jakub Trllo
882c0bcc6a rename decorator and add more information to the example 2025-10-20 14:58:26 +02:00
Jakub Trllo
bd0320f56f added planned break of backwards compatibility 2025-10-20 14:24:04 +02:00
Jakub Trllo
fbf370befa raise from previous exception 2025-10-20 14:23:53 +02:00
Jakub Trllo
50531fa35a added docstring 2025-10-20 14:23:39 +02:00
Jakub Trllo
6807664188 rename decorator function 2025-10-20 12:04:09 +02:00
Jakub Trllo
fae8e2b0d3
Merge branch 'develop' into enhancement/get-repre-path-function 2025-10-20 11:50:08 +02:00
Ondrej Samohel
0ca2d25ef6
⚗️ fix linting 2025-10-17 17:41:50 +02:00
Ondrej Samohel
f147d28c52
⚗️ add tests for product names 2025-10-17 17:36:47 +02:00
Jakub Jezek
0b51e17a8a
Fixes audio duplication in sibling instances.
Ensures audio is only added to relevant sibling
instances, preventing duplication.

- Prevents adding audio to the same instance.
- Streamlines audio assignment logic.
2025-10-17 16:21:32 +02:00
Ondrej Samohel
ff9167192a
Merge remote-tracking branch 'origin/enhancement/1297-product-base-types-creation-and-creator-plugins' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-10-17 15:15:16 +02:00
Ondřej Samohel
8906a1c903
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-10-17 15:13:21 +02:00
Ondrej Samohel
fa6d50c23e
Merge remote-tracking branch 'origin/develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-10-17 15:08:28 +02:00
Jakub Jezek
d8dab91619
Adds audio to sibling reviewable instances
Ensures audio is added to sibling instances
needing audio for reviewable media.

- Checks for sibling instances with the same
  parent ID.
- Adds audio information to those instances.
2025-10-16 11:32:55 +02:00
Jakub Jezek
b1cba11f6b
Skips audio collection in editorial contexts.
Prevents duplicate audio collection when editorial
context already handles audio processing.

- Introduces function to get audio instances.
- Checks for existing audio instances to avoid
  duplication.
- Skips default audio collection if audio is already
  provided.
2025-10-16 11:32:36 +02:00
timsergeeff
aabd9f7f50
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-10-10 21:55:23 +03:00
timsergeeff
2541f8909e
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-10-10 21:55:18 +03:00
timsergeeff
7ef330c3f4
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-10-10 21:55:12 +03:00
timsergeeff
0db3f67eb3
Remove unnecessary blank lines in transcoding.py 2025-10-10 15:06:51 +03:00
timsergeeff
862049d995
Refactor color conversion logic in transcoding.py 2025-10-10 13:00:09 +03:00
Jakub Trllo
4b2d2d5002 added overload definitions 2025-10-06 16:33:49 +02:00
Jakub Trllo
b665bf3f79 add attribute to the function to be able to detect if new version should be used 2025-10-06 13:10:39 +02:00
Jakub Trllo
f11800f1e7 fix type hint 2025-10-03 17:22:32 +02:00
Jakub Trllo
725e0f5a11 get rid of private function 2025-10-03 17:17:47 +02:00
Jakub Trllo
e59975fe95 add docstring 2025-10-03 17:10:17 +02:00
Jakub Trllo
5fd5b73e91 fix type hints 2025-10-03 17:05:19 +02:00
Jakub Trllo
a35b179ed1 remove the private variant of the function 2025-10-03 16:59:46 +02:00
Jakub Trllo
16b4584609 mark the function with an attribute to know if entities are expected in arguments 2025-10-03 16:50:57 +02:00
Jakub Trllo
31b023b0fa use only new signature 2025-10-03 16:47:14 +02:00
Jakub Trllo
348e11f968 wrap get_product_name function 2025-10-03 16:40:12 +02:00
Jakub Trllo
14fb34e4b6 remove unused import 2025-10-03 15:22:18 +02:00
Jakub Trllo
f7e9f6e7c9 use kwargs in default implementation 2025-10-03 15:17:51 +02:00
Jakub Trllo
fc7ca39f39 move comment to correct place 2025-10-03 15:13:19 +02:00
Jakub Trllo
07650130c6 initial support to use folder in product name template 2025-10-03 15:11:24 +02:00
Jakub Trllo
d81f6eaa3e remove unused import 2025-10-03 15:08:42 +02:00
Jakub Trllo
bc5c162a00 push to project uses simple action 2025-10-03 15:07:40 +02:00
Jakub Trllo
48cc1719e3 delivery action uses simple action 2025-10-03 15:07:17 +02:00
Jakub Trllo
d465e4a9b3 rename 'process' to 'execute_simple_action' 2025-10-03 15:05:54 +02:00
Jakub Trllo
6d1d1e01d4 use 'get_selected_version_entities' in delete old versions 2025-10-03 15:04:34 +02:00
Jakub Trllo
eedd982a84 use first representation in action item collection 2025-10-03 15:04:07 +02:00
Jakub Trllo
917c4e317c use ActionForm in delete old versions 2025-10-03 14:52:57 +02:00
Jakub Trllo
cff10604f9
Merge branch 'develop' into enhancement/1416-loader-actions 2025-10-03 11:30:41 +02:00
Jakub Trllo
e9958811d4 added helper conversion function for webaction fields 2025-10-02 17:15:53 +02:00
Jakub Trllo
55828c7341 move LoaderActionForm as ActionForm to structures 2025-10-02 16:58:21 +02:00
Jakub Trllo
fa28301952
Merge branch 'develop' into enhancement/1416-loader-actions 2025-10-02 14:52:00 +02:00
Jakub Trllo
0dfaa00165 remove unnecessary argument 2025-10-01 18:06:39 +02:00
Jakub Trllo
81a0b67640 remove action identifier 2025-10-01 16:43:32 +02:00
Jakub Trllo
365d0a95e0 fix typo 2025-10-01 12:20:14 +02:00
Jakub Trllo
90497bdd59 added some helper methods 2025-10-01 12:14:07 +02:00
Jakub Trllo
af196dd049 use simple plugin in export otio action 2025-10-01 12:02:04 +02:00
Jakub Trllo
76be69c4b2 add simple action plugin 2025-10-01 12:01:48 +02:00
Jakub Trllo
4c492b6d4b fetch only first representation 2025-10-01 11:54:58 +02:00
Jakub Trllo
66b1a6e8ad add small explanation to the code 2025-09-30 17:48:07 +02:00
Jakub Trllo
3945655f21 return type in docstring 2025-09-30 17:37:22 +02:00
Jakub Trllo
56fa213886
Merge branch 'develop' into enhancement/1416-loader-actions 2025-09-30 16:35:37 +02:00
Jakub Trllo
b05ccf3be8
Merge branch 'develop' into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-09-26 09:51:42 +02:00
Jakub Trllo
dcf5db31d0 formatting fix 2025-09-25 12:00:28 +02:00
Jakub Trllo
80f84e95fc add formatting 2025-09-25 11:54:02 +02:00
Jakub Trllo
efcd4425b7 add signature to the original function 2025-09-25 11:53:45 +02:00
Jakub Trllo
8c61e65521 handle backwards compatibility properly 2025-09-25 11:52:59 +02:00
Jakub Trllo
c9bb43059d remove doubled import 2025-09-25 11:36:56 +02:00
Jakub Trllo
d55ac4aa54 Use 'get_representation_path' for both signatures. 2025-09-25 11:34:47 +02:00
Jakub Trllo
ce3a59446c Merge branch 'develop' into enhancement/1416-loader-actions 2025-09-25 11:12:25 +02:00
Jakub Trllo
b026fe9b18
Merge branch 'develop' into enhancement/get-repre-path-function 2025-09-25 11:02:20 +02:00
Ondřej Samohel
8edd6c583d
Merge remote-tracking branch 'origin/develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-09-24 12:41:26 +02:00
Jakub Trllo
60ff1ddb0c use the new function 2025-09-24 12:14:21 +02:00
Jakub Trllo
80ba7ea5ed implement new 'get_representation_path_v2' function 2025-09-24 12:12:30 +02:00
Ondrej Samohel
f5ac5c2cfb
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-09-23 13:34:35 +02:00
Jakub Trllo
8fdbda78ee modify loader tool to match changes in backend 2025-09-18 16:37:07 +02:00
Jakub Trllo
a7b379059f allow to pass data into action items 2025-09-18 15:43:59 +02:00
Jakub Trllo
8bbd15c482 added some docstrings 2025-09-18 13:11:46 +02:00
Jakub Trllo
291930b78d Merge branch 'develop' into enhancement/1416-loader-actions
# Conflicts:
#	client/ayon_core/tools/loader/control.py
2025-09-18 10:14:44 +02:00
Jakub Trllo
670bf7f6ab
Merge branch 'develop' into enhancement/1416-loader-actions 2025-09-09 12:00:30 +02:00
Jakub Trllo
4c25826a9c Merge branch 'develop' into enhancement/1416-loader-actions
# Conflicts:
#	client/ayon_core/plugins/load/push_to_project.py
2025-09-08 17:02:36 +02:00
Ondřej Samohel
51965a9de1
🔥 remove unused import 2025-09-03 15:18:50 +02:00
Ondřej Samohel
2597469b30
🔥 remove deprecated code 2025-09-03 15:16:27 +02:00
Ondřej Samohel
93bf258978
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-09-03 14:54:20 +02:00
Jakub Trllo
b560bb356e fix host name checks 2025-08-28 12:19:56 +02:00
Jakub Trllo
15a3f9d29a fix 'representations' -> 'representation' 2025-08-28 12:12:32 +02:00
Jakub Trllo
3a6e993158
Merge branch 'develop' into enhancement/1416-loader-actions 2025-08-26 16:06:08 +02:00
Aleks Berland
827cf15bf2
Merge branch 'develop' into ayon_review_integrate_timeout_retries_and_fallback_with_help 2025-08-26 09:56:03 -04:00
Aleks Berland
32c022cd4d Refactor upload retry logic to handle only transient network issues and improve error handling 2025-08-26 09:55:47 -04:00
Jakub Trllo
751ad94343 few fixes in entities cache 2025-08-26 15:51:19 +02:00
Jakub Trllo
cf62eede8a use already cached entities 2025-08-26 15:50:55 +02:00
Aleks Berland
a0f6a3f379 Implement upload retries for reviewable files and add user-friendly error handling in case of timeout. Update validation help documentation for upload failures. 2025-08-25 19:09:20 -04:00
Jakub Trllo
c4b47950a8 formatting fixes 2025-08-25 18:07:12 +02:00
Jakub Trllo
b1a4d5dfc5 remove docstring 2025-08-25 18:05:34 +02:00
Jakub Trllo
062069028f convert delivery action 2025-08-25 18:05:28 +02:00
Jakub Trllo
fc0232b744 convert open file action 2025-08-25 17:57:39 +02:00
Jakub Trllo
79ca56f3ad added identifier to push to project plugin 2025-08-25 17:33:43 +02:00
Jakub Trllo
ed6247d231 converted otio export action 2025-08-25 17:32:00 +02:00
Jakub Trllo
2a13074e6b Converted push to project plugin 2025-08-25 17:04:20 +02:00
Jakub Trllo
f784eeb17e remove unused imports 2025-08-25 16:43:10 +02:00
Jakub Trllo
47fc15faf0
Merge branch 'develop' into enhancement/1416-loader-actions 2025-08-25 16:42:04 +02:00
Jakub Trllo
0ad0b3927f small enhancements of messages 2025-08-25 16:31:23 +02:00
Jakub Trllo
f100a6c563 show grouped actions as menu 2025-08-25 16:31:07 +02:00
Jakub Trllo
1768543b8b safe-guards for optional action and menu 2025-08-25 16:18:18 +02:00
Jakub Trllo
f06fbe159f added group label to 'ActionItem' 2025-08-25 15:00:10 +02:00
Jakub Trllo
270d7cbff9 convert delete old versions actions 2025-08-25 12:44:16 +02:00
Jakub Trllo
c2cdd4130e better stretch, margins and spacing 2025-08-25 11:25:09 +02:00
Jakub Trllo
51beef8192 handle the actions 2025-08-25 11:22:22 +02:00
Jakub Trllo
856aa31231 change order of arguments 2025-08-25 10:57:40 +02:00
Jakub Trllo
c6c642f37a added json conversions 2025-08-25 10:47:20 +02:00
Jakub Trllo
2be5d3b72b fix type comparison 2025-08-22 11:46:49 +02:00
Jakub Trllo
8bdfe806e0 result can contain form values
This allows to re-open the same dialog having the same default values but with values already filled from user
2025-08-22 11:46:27 +02:00
Jakub Trllo
e30738d79b LoaderSelectedType is public 2025-08-22 11:45:48 +02:00
Jakub Trllo
8da213c566 added host to the context 2025-08-22 11:45:26 +02:00
Jakub Trllo
d0cb16a155 pass context to loader action plugins 2025-08-22 11:44:55 +02:00
Jakub Trllo
afc1af7e95 use kwargs 2025-08-21 16:42:32 +02:00
Jakub Trllo
12d4905b39 base implementation in loader tool 2025-08-21 16:41:30 +02:00
Jakub Trllo
234ac09f42 added enabled option to plugin 2025-08-21 16:17:29 +02:00
Jakub Trllo
39dc54b09e return output of execute action 2025-08-21 16:17:18 +02:00
Jakub Trllo
b5ab3d3380 different way how to set plugin id 2025-08-21 16:17:01 +02:00
Jakub Trllo
db764619fc sort actions at different place 2025-08-21 16:06:39 +02:00
Jakub Trllo
a22f378ed5 added 'get_loader_action_plugin_paths' to 'IPluginPaths' 2025-08-21 15:10:03 +02:00
Jakub Trllo
3a65c56123 import Anatomy directly 2025-08-21 15:09:11 +02:00
Jakub Trllo
422968315e do not hard force plugin identifier 2025-08-21 15:09:01 +02:00
Jakub Trllo
e7439a2d7f fix fill of plugin identifier 2025-08-21 15:08:33 +02:00
Jakub Trllo
e05ffe0263 converted copy file action 2025-08-21 15:08:04 +02:00
Jakub Trllo
700006692a added order and icon to 2025-08-21 14:15:02 +02:00
Jakub Trllo
0f65fe34a7 change entity type to str 2025-08-21 14:02:19 +02:00
Jakub Trllo
7b81cb1215 added logger to action plugin 2025-08-21 13:54:38 +02:00
Jakub Trllo
599716fe94 base of loader action 2025-08-21 11:23:10 +02:00
Jakub Trllo
dee1d51640 cache entities 2025-08-20 18:13:30 +02:00
Jakub Trllo
b3c5933042 use version contexts instead of product contexts 2025-08-20 18:12:27 +02:00
Jakub Trllo
29b3794dd8 only one method to get actions 2025-08-20 17:59:56 +02:00
Jakub Trllo
53848ad366 keep entity ids and entity type on action item 2025-08-20 17:53:03 +02:00
Jakub Trllo
723932cfac reduced information that is used in loader for action item 2025-08-20 15:38:56 +02:00
Jakub Trllo
5e3b38376c separated discover logic from 'PluginDiscoverContext' 2025-08-20 15:37:16 +02:00
Jakub Trllo
bd94d7ede6 move 'StrEnum' to lib 2025-08-20 15:36:39 +02:00
Jakub Trllo
1e1828bbdc moved current actions to subdir 2025-08-20 15:21:40 +02:00
Roy Nieterau
da83767fa2
Merge branch 'develop' into enhancement/collect_scene_loaded_versions_add_more_hosts 2025-07-23 12:10:14 +02:00
Roy Nieterau
3d61201608 Collect Loaded Scene Versions: Enable for more hosts 2025-07-23 12:04:17 +02:00
Roy Nieterau
f17fa50456 Merge branch 'develop' of https://github.com/ynput/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-07-13 16:09:11 +02:00
Roy Nieterau
49278fb63d Merge branch 'develop' of https://github.com/ynput/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-07-01 16:53:17 +02:00
Roy Nieterau
4629a09036
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-23 13:32:34 +02:00
Roy Nieterau
9dbaf15449
Merge branch 'develop' into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-06-19 14:20:59 +02:00
Roy Nieterau
a62c6df126
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-17 14:59:13 +02:00
Ondřej Samohel
3e77031d9c
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-06-13 16:42:27 +02:00
Roy Nieterau
27c42bb865
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-12 16:42:55 +02:00
Roy Nieterau
4237468bcf
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-12 14:30:15 +02:00
Roy Nieterau
1a623ff853 Merge branch '989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2' of https://github.com/BigRoy/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-11 20:55:36 +02:00
Roy Nieterau
55bfd79cf3 Check against .upper() instead of .lower() to match strings more with how they are compared later in the code (improve style consistency) 2025-06-11 20:55:21 +02:00
Roy Nieterau
0c23ecc70d Add support for red, green, blue and alpha 2025-06-11 20:52:08 +02:00
Jakub Ježek
f94c6a0408
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-11 11:39:43 +02:00
Ondřej Samohel
50045d71bd
support product base types in the integrator 2025-06-10 12:16:49 +02:00
Ondřej Samohel
e2a413f20e
🐶 remove unneeded f-string 2025-06-10 11:40:43 +02:00
Ondřej Samohel
da286e3cfb
♻️ remove check for attribute 2025-06-10 11:23:37 +02:00
Ondřej Samohel
7f21d39d81
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins 2025-06-09 14:06:28 +02:00
Ondřej Samohel
fa8c054889
♻️ refactor support feature check function name 2025-06-09 13:54:41 +02:00
Roy Nieterau
6061e8a82b Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call
# Conflicts:
#	client/ayon_core/lib/transcoding.py
#	client/ayon_core/plugins/publish/extract_color_transcode.py
2025-06-06 14:50:06 +02:00
Roy Nieterau
4aa2f1bb86 Merge branch 'enhancement/transcoding_oiio_tool_for_ffmpeg_one_call' of https://github.com/BigRoy/ayon-core into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-06-06 14:48:00 +02:00
Roy Nieterau
8fbb8c93c1 Allow more frames patterns 2025-06-06 14:47:41 +02:00
Ondřej Samohel
dfd8fe6e8c
🐛 report correctly skipped abstract creators 2025-06-06 10:33:25 +02:00
Philippe Leprince
2ee31c77d4
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-06-04 17:18:02 +02:00
Ondrej Samohel
fce1ef248d
🐶 some more linter fixes 2025-06-04 11:28:07 +02:00
Ondrej Samohel
67db5c123f
🐶 linter fixes 2025-06-04 11:24:22 +02:00
Ondrej Samohel
d237e5f54c
🎨 add support for product base type to basic creator logic 2025-06-03 17:25:32 +02:00
Ondřej Samohel
9e730a6b5b
🔧 changes in Plugin anc CreateInstance WIP 2025-06-03 10:58:43 +02:00
Ondrej Samohel
bcdeba18ac
🔧 implementation WIP 2025-06-02 09:29:04 +02:00
Roy Nieterau
204625b5c8
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-05-20 23:55:29 +02:00
Roy Nieterau
00921e7806
Update client/ayon_core/lib/transcoding.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-19 12:05:28 +02:00
Roy Nieterau
5917671521 Add AR, AG, AB test case and fix behavior 2025-05-19 12:04:01 +02:00
Roy Nieterau
526e5bfabb Add unittest 2025-05-19 12:01:14 +02:00
Roy Nieterau
fa1820ab97 Merge branch '989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2' of https://github.com/BigRoy/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-05-19 11:19:05 +02:00
Roy Nieterau
b8ea018b43 Clarify exception 2025-05-19 11:18:37 +02:00
Roy Nieterau
72895df6ae Match variable name more with captured exception 2025-05-19 11:16:52 +02:00
Roy Nieterau
9f3faa0e46
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-05-17 15:48:03 +02:00
Roy Nieterau
7fa192229c Improve docstring 2025-05-17 15:47:50 +02:00
Roy Nieterau
44dc1ea99e Include message of the original raised error 2025-05-17 15:43:41 +02:00
Roy Nieterau
afbf2c8848 Refactor UnknownRGBAChannelsError -> MissingRGBAChannelsError 2025-05-17 15:40:54 +02:00
Roy Nieterau
a093e1e9c9 Merge branch '989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2' of https://github.com/BigRoy/ayon-core into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-05-17 15:39:23 +02:00
Roy Nieterau
90070bc8ef Merge remote-tracking branch 'origin/bugfix/transcode_ignore_conversion_on_unknown_channel' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2
# Conflicts:
#	client/ayon_core/lib/transcoding.py
2025-05-17 15:39:00 +02:00
Roy Nieterau
82b6837dc2
Merge branch 'develop' into 989-ay-7315_extract-review-and-oiio-transcode-failing-to-transcode-media-blocking-publishes-2 2025-05-13 15:55:16 +02:00
Roy Nieterau
2d7bd487ba Allow review/transcoding of more channels, like "Y", "XYZ", "AR", "AG", "AB" 2025-05-13 15:40:40 +02:00
Roy Nieterau
3248faff40 Fix int -> str frame padding argument to subprocess 2025-04-25 14:57:40 +02:00
Roy Nieterau
ec9c6c510a Split on any of the characters as intended, instead of on literal x- 2025-04-25 14:14:27 +02:00
Roy Nieterau
537dac6033 Fix get_oiio_info_for_input call for sequences in convert_colorspace 2025-04-25 12:47:20 +02:00
Roy Nieterau
422febf441 Fix variable usage 2025-04-25 12:26:50 +02:00
Roy Nieterau
7bf2bfd6b1 Improve docstring 2025-04-25 12:22:22 +02:00
Roy Nieterau
ea5f1c81d6 Fix passing sequence to oiiotool 2025-04-25 12:21:34 +02:00
Roy Nieterau
7b91c0da1e Merge branch 'enhancement/transcoding_oiio_tool_for_ffmpeg_one_call' of https://github.com/BigRoy/ayon-core into enhancement/oiio_transcode_parallel_frames 2025-04-25 09:27:27 +02:00
Roy Nieterau
849a999744 Fix TypeError message 2025-04-25 09:11:44 +02:00
Roy Nieterau
01174c9b11 Provide more sensible return type for _translate_to_sequence 2025-04-25 09:10:44 +02:00
Roy Nieterau
a94bda06f4 Merge branch 'develop' of https://github.com/ynput/ayon-core into enhancement/oiio_transcode_parallel_frames
# Conflicts:
#	client/ayon_core/plugins/publish/extract_color_transcode.py
2025-04-25 09:03:35 +02:00
Roy Nieterau
0aa0673b57 Use correct variable 2025-04-23 19:50:59 +02:00
Roy Nieterau
c79ae86c44
Merge branch 'develop' into enhancement/transcoding_oiio_tool_for_ffmpeg_one_call 2025-04-23 19:50:00 +02:00
Roy Nieterau
e8a0c69cf2 Merge branch 'enhancement/transcoding_oiio_tool_for_ffmpeg_one_call' of https://github.com/BigRoy/ayon-core into enhancement/oiio_transcode_parallel_frames
# Conflicts:
#	client/ayon_core/lib/transcoding.py
2025-04-23 19:49:15 +02:00
Roy Nieterau
98e0ec1051 Improve parallelization for ExtractReview and ExtractOIIOTranscode
- Support ExtractReview convert to FFMPEG in one `oiiotool` call for sequences
- Support sequences with holes in both plug-ins by using dedicated `--frames` argument to `oiiotool` for more complex frame patterns.
- Add `--parallel-frames` argument to `oiiotool` to allow parallelizing more of the OIIO tool process, improving throughput. Note: This requires OIIO 2.5.2.0 or higher. See f40f9800c8
2025-04-23 19:44:39 +02:00
Juan M
08aee24a48
Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-04-04 14:47:51 +01:00
Juan M
b6296423f5
Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-04-04 14:42:55 +01:00
Juan M
66ecc40a80
Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-04-04 14:42:45 +01:00
Juan M
1e3aaa887d
Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py
Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com>
2025-04-04 14:41:39 +01:00
Juan M
726c259ec3
Merge branch 'develop' into enhancing-publisher-card-readability 2025-04-04 12:38:00 +01:00
jm22dogs
445dd4ec5b add card sublabel with folder and task name 2025-04-04 12:32:15 +01:00
Roy Nieterau
04c14cab7a Remove deprecated function import 2025-04-01 09:09:50 +02:00
Roy Nieterau
b43969da1c add from __future__ import annotations 2025-04-01 09:08:12 +02:00
Roy Nieterau
c7c2a4a7ec Cleanup 2025-04-01 09:07:02 +02:00
Roy Nieterau
cb125a192f Optimize oiio tool conversion for ffmpeg.
- Prepare attributes to remove list just once.
- Process sequences as a single `oiiotool` call
2025-03-31 23:15:17 +02:00
Roy Nieterau
5f82473a26 Merge branch 'develop' of https://github.com/ynput/ayon-core into bugfix/transcode_ignore_conversion_on_unknown_channel
# Conflicts:
#	client/ayon_core/plugins/publish/extract_color_transcode.py
2025-03-18 00:16:50 +01:00
Roy Nieterau
363824d589 Move logic to make it clearer that we always process the same input files of the source representation but used for multiple output profiles 2024-11-06 09:12:19 +01:00
Roy Nieterau
d072da86d1 Stop processing directly on unknown RGBA channel for a representation 2024-11-06 09:11:22 +01:00
Roy Nieterau
148ce21a9a Allow ExtractOIIOTranscode to pass with a warning if it can't find the RGBA channels in source media instead of raising error 2024-11-06 09:09:12 +01:00
125 changed files with 7837 additions and 2695 deletions

View file

@ -35,6 +35,14 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.7.0
- 1.6.13
- 1.6.12
- 1.6.11
- 1.6.10
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
- 1.6.4

View file

@ -185,6 +185,20 @@ class IPluginPaths(AYONInterface):
"""
return self._get_plugin_paths_by_type("inventory")
def get_loader_action_plugin_paths(
self, host_name: Optional[str]
) -> list[str]:
"""Receive loader action plugin paths.
Args:
host_name (Optional[str]): Current host name.
Returns:
list[str]: Paths to loader action plugins.
"""
return []
class ITrayAddon(AYONInterface):
"""Addon has special procedures when used in Tray tool.

View file

@ -6,7 +6,6 @@ import logging
import code
import traceback
from pathlib import Path
import warnings
import click
@ -90,54 +89,6 @@ def addon(ctx):
pass
@main_cli.command()
@click.pass_context
@click.argument("output_json_path")
@click.option("--project", help="Project name", default=None)
@click.option("--asset", help="Folder path", default=None)
@click.option("--task", help="Task name", default=None)
@click.option("--app", help="Application name", default=None)
@click.option(
"--envgroup", help="Environment group (e.g. \"farm\")", default=None
)
def extractenvironments(
ctx, output_json_path, project, asset, task, app, envgroup
):
"""Extract environment variables for entered context to a json file.
Entered output filepath will be created if does not exists.
All context options must be passed otherwise only AYON's global
environments will be extracted.
Context options are "project", "asset", "task", "app"
Deprecated:
This function is deprecated and will be removed in future. Please use
'addon applications extractenvironments ...' instead.
"""
warnings.warn(
(
"Command 'extractenvironments' is deprecated and will be"
" removed in future. Please use"
" 'addon applications extractenvironments ...' instead."
),
DeprecationWarning
)
addons_manager = ctx.obj["addons_manager"]
applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is None:
raise RuntimeError(
"Applications addon is not available or enabled."
)
# Please ignore the fact this is using private method
applications_addon._cli_extract_environments(
output_json_path, project, asset, task, app, envgroup
)
@main_cli.command()
@click.pass_context
@click.argument("path", required=True)

View file

@ -1,11 +1,4 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value
from ayon_core.lib import StrEnum
class ContextChangeReason(StrEnum):

View file

@ -137,7 +137,7 @@ class HostBase(AbstractHost):
def get_current_folder_path(self) -> Optional[str]:
"""
Returns:
Optional[str]: Current asset name.
Optional[str]: Current folder path.
"""
return os.environ.get("AYON_FOLDER_PATH")

View file

@ -2,6 +2,7 @@
# flake8: noqa E402
"""AYON lib functions."""
from ._compatibility import StrEnum
from .local_settings import (
IniSettingRegistry,
JSONSettingRegistry,
@ -142,6 +143,8 @@ from .ayon_info import (
terminal = Terminal
__all__ = [
"StrEnum",
"IniSettingRegistry",
"JSONSettingRegistry",
"AYONSecureRegistry",

View file

@ -0,0 +1,8 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value

View file

@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None:
return copy.deepcopy(self.default)
return list(self._item_values.intersection(value))
return [
v
for v in value
if v in self._item_values
]
def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values."""

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import os
import re
import logging
@ -12,6 +13,8 @@ from typing import Optional
import xml.etree.ElementTree
import clique
from .execute import run_subprocess
from .vendor_bin_utils import (
get_ffmpeg_tool_args,
@ -110,6 +113,15 @@ def deprecated(new_destination):
return _decorator(func)
class MissingRGBAChannelsError(ValueError):
"""Raised when we can't find channels to use as RGBA for conversion in
input media.
This may be other channels than solely RGBA, like Z-channel. The error is
raised when no matching 'reviewable' channel was found.
"""
def get_transcode_temp_directory():
"""Creates temporary folder for transcoding.
@ -122,16 +134,29 @@ def get_transcode_temp_directory():
)
def get_oiio_info_for_input(filepath, logger=None, subimages=False):
def get_oiio_info_for_input(
filepath: str,
*,
subimages: bool = False,
verbose: bool = True,
logger: logging.Logger = None,
):
"""Call oiiotool to get information about input and return stdout.
Args:
filepath (str): Path to file.
subimages (bool): include info about subimages in the output.
verbose (bool): get the full metadata about each input image.
logger (logging.Logger): Logger used for logging.
Stdout should contain xml format string.
"""
args = get_oiio_tool_args(
"oiiotool",
"--info",
"-v"
)
if verbose:
args.append("-v")
if subimages:
args.append("-a")
@ -388,6 +413,10 @@ def get_review_info_by_layer_name(channel_names):
...
]
This tries to find suitable outputs good for review purposes, by
searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB
channels.
Args:
channel_names (list[str]): List of channel names.
@ -396,7 +425,6 @@ def get_review_info_by_layer_name(channel_names):
"""
layer_names_order = []
rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names:
@ -405,45 +433,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1)
channels_by_layer_name[layer_name][channel_name] = last_part
if last_part.lower() not in {
"r", "red",
"g", "green",
"b", "blue",
"a", "alpha"
# R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
channel = last_part.upper()
if channel not in {
# Detect RGBA channels
"R", "G", "B", "A",
# Support fully written out rgba channel names
"RED", "GREEN", "BLUE", "ALPHA",
# Allow detecting of x, y and z channels, and normal channels
"X", "Y", "Z", "N",
# red, green and blue alpha/opacity, for colored mattes
"AR", "AG", "AB"
}:
continue
if layer_name not in layer_names_order:
layer_names_order.append(layer_name)
# R, G, B or A
channel = last_part[0].upper()
rgba_by_layer_name[layer_name][channel] = channel_name
channels_by_layer_name[layer_name][channel] = channel_name
# Put empty layer or 'rgba' to the beginning of the list
# - if input has R, G, B, A channels they should be used for review
# NOTE They are iterated in reversed order because they're inserted to
# the beginning of 'layer_names_order' -> last added will be first.
for name in reversed(["", "rgba"]):
if name in layer_names_order:
layer_names_order.remove(name)
layer_names_order.insert(0, name)
def _sort(_layer_name: str) -> int:
# Prioritize "" layer name
# Prioritize layers with RGB channels
if _layer_name == "rgba":
return 0
if _layer_name == "":
return 1
channels = channels_by_layer_name[_layer_name]
if all(channel in channels for channel in "RGB"):
return 2
return 10
layer_names_order.sort(key=_sort)
output = []
for layer_name in layer_names_order:
rgba_layer_info = rgba_by_layer_name[layer_name]
red = rgba_layer_info.get("R")
green = rgba_layer_info.get("G")
blue = rgba_layer_info.get("B")
if not red or not green or not blue:
channel_info = channels_by_layer_name[layer_name]
alpha = channel_info.get("A")
# RGB channels
if all(channel in channel_info for channel in "RGB"):
rgb = "R", "G", "B"
# RGB channels using fully written out channel names
elif all(
channel in channel_info
for channel in ("RED", "GREEN", "BLUE")
):
rgb = "RED", "GREEN", "BLUE"
alpha = channel_info.get("ALPHA")
# XYZ channels (position pass)
elif all(channel in channel_info for channel in "XYZ"):
rgb = "X", "Y", "Z"
# Colored mattes (as defined in OpenEXR Channel Name standards)
elif all(channel in channel_info for channel in ("AR", "AG", "AB")):
rgb = "AR", "AG", "AB"
# Luminance channel (as defined in OpenEXR Channel Name standards)
elif "Y" in channel_info:
rgb = "Y", "Y", "Y"
# Has only Z channel (Z-depth layer)
elif "Z" in channel_info:
rgb = "Z", "Z", "Z"
# Has only A channel (Alpha layer)
elif "A" in channel_info:
rgb = "A", "A", "A"
alpha = None
else:
# No reviewable channels found
continue
red = channel_info[rgb[0]]
green = channel_info[rgb[1]]
blue = channel_info[rgb[2]]
output.append({
"name": layer_name,
"review_channels": {
"R": red,
"G": green,
"B": blue,
"A": rgba_layer_info.get("A"),
"A": alpha,
}
})
return output
@ -508,7 +586,10 @@ def get_review_layer_name(src_filepath):
return None
# Load info about file from oiio tool
input_info = get_oiio_info_for_input(src_filepath)
input_info = get_oiio_info_for_input(
src_filepath,
verbose=False,
)
if not input_info:
return None
@ -572,6 +653,37 @@ def should_convert_for_ffmpeg(src_filepath):
return False
def _get_attributes_to_erase(
input_info: dict, logger: logging.Logger
) -> list[str]:
"""FFMPEG does not support some attributes in metadata."""
erase_attrs: dict[str, str] = {} # Attr name to reason mapping
for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue
# Remove attributes that have string value longer than allowed length
# for ffmpeg or when contain prohibited symbols
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
reason = f"has too long value ({len(attr_value)} chars)."
erase_attrs[attr_name] = reason
continue
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char not in attr_value:
continue
reason = f"contains unsupported character \"{char}\"."
erase_attrs[attr_name] = reason
break
for attr_name, reason in erase_attrs.items():
logger.info(
f"Removed attribute \"{attr_name}\" from metadata"
f" because {reason}."
)
return list(erase_attrs.keys())
def convert_input_paths_for_ffmpeg(
input_paths,
output_dir,
@ -597,7 +709,7 @@ def convert_input_paths_for_ffmpeg(
Raises:
ValueError: If input filepath has extension not supported by function.
Currently is supported only ".exr" extension.
Currently, only ".exr" extension is supported.
"""
if logger is None:
logger = logging.getLogger(__name__)
@ -622,7 +734,22 @@ def convert_input_paths_for_ffmpeg(
# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
for input_path in input_paths:
# Find which attributes to strip
erase_attributes: list[str] = _get_attributes_to_erase(
input_info, logger=logger
)
# clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so
# we use a customized pattern.
pattern = "[_.](?P<index>(?P<padding>0*)\\d+)\\.\\D+\\d?$"
input_collections, input_remainder = clique.assemble(
input_paths,
patterns=[pattern],
assume_padded_when_ambiguous=True,
)
input_items = list(input_collections)
input_items.extend(input_remainder)
for input_item in input_items:
# Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args(
"oiiotool",
@ -633,8 +760,23 @@ def convert_input_paths_for_ffmpeg(
if compression:
oiio_cmd.extend(["--compression", compression])
# Convert a sequence of files using a single oiiotool command
# using its sequence syntax
if isinstance(input_item, clique.Collection):
frames = input_item.format("{head}#{tail}").replace(" ", "")
oiio_cmd.extend([
"--framepadding", input_item.padding,
"--frames", frames,
"--parallel-frames"
])
input_item: str = input_item.format("{head}#{tail}")
elif not isinstance(input_item, str):
raise TypeError(
f"Input is not a string or Collection: {input_item}"
)
oiio_cmd.extend([
input_arg, input_path,
input_arg, input_item,
# Tell oiiotool which channels should be put to top stack
# (and output)
"--ch", channels_arg,
@ -642,38 +784,11 @@ def convert_input_paths_for_ffmpeg(
"--subimage", "0"
])
for attr_name, attr_value in input_info["attribs"].items():
if not isinstance(attr_value, str):
continue
# Remove attributes that have string value longer than allowed
# length for ffmpeg or when containing prohibited symbols
erase_reason = "Missing reason"
erase_attribute = False
if len(attr_value) > MAX_FFMPEG_STRING_LEN:
erase_reason = "has too long value ({} chars).".format(
len(attr_value)
)
erase_attribute = True
if not erase_attribute:
for char in NOT_ALLOWED_FFMPEG_CHARS:
if char in attr_value:
erase_attribute = True
erase_reason = (
"contains unsupported character \"{}\"."
).format(char)
break
if erase_attribute:
# Set attribute to empty string
logger.info((
"Removed attribute \"{}\" from metadata because {}."
).format(attr_name, erase_reason))
oiio_cmd.extend(["--eraseattrib", attr_name])
for attr_name in erase_attributes:
oiio_cmd.extend(["--eraseattrib", attr_name])
# Add last argument - path to output
base_filename = os.path.basename(input_path)
base_filename = os.path.basename(input_item)
output_path = os.path.join(output_dir, base_filename)
oiio_cmd.extend([
"-o", output_path
@ -1074,7 +1189,10 @@ def oiio_color_convert(
target_display=None,
target_view=None,
additional_command_args=None,
logger=None,
frames: Optional[str] = None,
frame_padding: Optional[int] = None,
parallel_frames: bool = False,
logger: Optional[logging.Logger] = None,
):
"""Transcode source file to other with colormanagement.
@ -1086,7 +1204,7 @@ def oiio_color_convert(
input_path (str): Path that should be converted. It is expected that
contains single file or image sequence of same type
(sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs,
eg `big.1-3#.tif`)
eg `big.1-3#.tif` or `big.1-3%d.ext` with `frames` argument)
output_path (str): Path to output filename.
(must follow format of 'input_path', eg. single file or
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`)
@ -1107,6 +1225,13 @@ def oiio_color_convert(
both 'view' and 'display' must be filled (if 'target_colorspace')
additional_command_args (list): arguments for oiiotool (like binary
depth for .dpx)
frames (Optional[str]): Complex frame range to process. This requires
input path and output path to use frame token placeholder like
`#` or `%d`, e.g. file.#.exr
frame_padding (Optional[int]): Frame padding to use for the input and
output when using a sequence filepath.
parallel_frames (bool): If True, process frames in parallel inside
the `oiiotool` process. Only supported in OIIO 2.5.20.0+.
logger (logging.Logger): Logger used for logging.
Raises:
@ -1116,7 +1241,20 @@ def oiio_color_convert(
if logger is None:
logger = logging.getLogger(__name__)
input_info = get_oiio_info_for_input(input_path, logger=logger)
# Get oiioinfo only from first image, otherwise file can't be found
first_input_path = input_path
if frames:
frames: str
first_frame = int(re.split("[ x-]", frames, 1)[0])
first_frame = str(first_frame).zfill(frame_padding or 0)
for token in ["#", "%d"]:
first_input_path = first_input_path.replace(token, first_frame)
input_info = get_oiio_info_for_input(
first_input_path,
verbose=False,
logger=logger,
)
# Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
@ -1129,6 +1267,22 @@ def oiio_color_convert(
"--colorconfig", config_path
)
if frames:
# If `frames` is specified, then process the input and output
# as if it's a sequence of frames (must contain `%04d` as frame
# token placeholder in filepaths)
oiio_cmd.extend([
"--frames", frames,
])
if frame_padding:
oiio_cmd.extend([
"--framepadding", str(frame_padding),
])
if parallel_frames:
oiio_cmd.append("--parallel-frames")
oiio_cmd.extend([
input_arg, input_path,
# Tell oiiotool which channels should be put to top stack
@ -1170,31 +1324,45 @@ def oiio_color_convert(
# Handle the different conversion cases
# Source view and display are known
if source_view and source_display:
color_convert_args = None
ocio_display_args = None
if target_colorspace:
# This is a two-step conversion process since there's no direct
# display/view to colorspace command
# This could be a config parameter or determined from OCIO config
# Use temporarty role space 'scene_linear'
# Use temporary role space 'scene_linear'
color_convert_args = ("scene_linear", target_colorspace)
elif source_display != target_display or source_view != target_view:
# Complete display/view pair conversion
# - go through a reference space
color_convert_args = (target_display, target_view)
ocio_display_args = (target_display, target_view)
else:
color_convert_args = None
logger.debug(
"Source and target display/view pairs are identical."
" No color conversion needed."
)
if color_convert_args:
if color_convert_args or ocio_display_args:
# Invert source display/view so that we can go from there to the
# target colorspace or display/view
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if color_convert_args:
# Use colorconvert for colorspace target
oiio_cmd.extend([
"--colorconvert:subimages=0",
*color_convert_args
])
elif ocio_display_args:
# Use ociodisplay for display/view target
oiio_cmd.extend([
"--ociodisplay:subimages=0",
*ocio_display_args
])
elif target_colorspace:
# Standard color space to color space conversion
@ -1219,24 +1387,6 @@ def oiio_color_convert(
run_subprocess(oiio_cmd, logger=logger)
def split_cmd_args(in_args):
"""Makes sure all entered arguments are separated in individual items.
Split each argument string with " -" to identify if string contains
one or more arguments.
Args:
in_args (list): of arguments ['-n', '-d uint10']
Returns
(list): ['-n', '-d', 'unint10']
"""
splitted_args = []
for arg in in_args:
if not arg.strip():
continue
splitted_args.extend(arg.split(" "))
return splitted_args
def get_rescaled_command_arguments(
application,
input_path,
@ -1318,7 +1468,11 @@ def get_rescaled_command_arguments(
command_args.extend(["-vf", "{0},{1}".format(scale, pad)])
elif application == "oiiotool":
input_info = get_oiio_info_for_input(input_path, logger=log)
input_info = get_oiio_info_for_input(
input_path,
verbose=False,
logger=log,
)
# Collect channels to export
_, channels_arg = get_oiio_input_and_channel_args(
input_info, alpha_default=1.0)
@ -1409,7 +1563,11 @@ def _get_image_dimensions(application, input_path, log):
# fallback for weird files with width=0, height=0
if (input_width == 0 or input_height == 0) and application == "oiiotool":
# Load info about file from oiio tool
input_info = get_oiio_info_for_input(input_path, logger=log)
input_info = get_oiio_info_for_input(
input_path,
verbose=False,
logger=log,
)
if input_info:
input_width = int(input_info["width"])
input_height = int(input_info["height"])
@ -1458,17 +1616,21 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
"""Get input and channel arguments for oiiotool.
Args:
oiio_input_info (dict): Information about input from oiio tool.
Should be output of function `get_oiio_info_for_input`.
Should be output of function 'get_oiio_info_for_input' (can be
called with 'verbose=False').
alpha_default (float, optional): Default value for alpha channel.
Returns:
tuple[str, str]: Tuple of input and channel arguments.
"""
channel_names = oiio_input_info["channelnames"]
review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None:
raise ValueError(
"Couldn't find channels that can be used for conversion."
raise MissingRGBAChannelsError(
"Couldn't find channels that can be used for conversion "
f"among channels: {channel_names}."
)
red, green, blue, alpha = review_channels
@ -1482,7 +1644,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
channels_arg += ",A={}".format(float(alpha_default))
input_channels.append("A")
input_channels_str = ",".join(input_channels)
# Make sure channels are unique, but preserve order to avoid oiiotool crash
input_channels_str = ",".join(list(dict.fromkeys(input_channels)))
subimages = oiio_input_info.get("subimages")
input_arg = "-i"

View file

@ -0,0 +1,62 @@
from .structures import (
ActionForm,
)
from .utils import (
webaction_fields_to_attribute_defs,
)
from .loader import (
LoaderSelectedType,
LoaderActionResult,
LoaderActionItem,
LoaderActionPlugin,
LoaderActionSelection,
LoaderActionsContext,
SelectionEntitiesCache,
LoaderSimpleActionPlugin,
)
from .launcher import (
LauncherAction,
LauncherActionSelection,
discover_launcher_actions,
register_launcher_action,
register_launcher_action_path,
)
from .inventory import (
InventoryAction,
discover_inventory_actions,
register_inventory_action,
register_inventory_action_path,
deregister_inventory_action,
deregister_inventory_action_path,
)
__all__ = (
"ActionForm",
"webaction_fields_to_attribute_defs",
"LoaderSelectedType",
"LoaderActionResult",
"LoaderActionItem",
"LoaderActionPlugin",
"LoaderActionSelection",
"LoaderActionsContext",
"SelectionEntitiesCache",
"LoaderSimpleActionPlugin",
"LauncherAction",
"LauncherActionSelection",
"discover_launcher_actions",
"register_launcher_action",
"register_launcher_action_path",
"InventoryAction",
"discover_inventory_actions",
"register_inventory_action",
"register_inventory_action_path",
"deregister_inventory_action",
"deregister_inventory_action_path",
)

View file

@ -0,0 +1,108 @@
import logging
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.load.utils import get_representation_path_from_context
class InventoryAction:
"""A custom action for the scene inventory tool
If registered the action will be visible in the Right Mouse Button menu
under the submenu "Actions".
"""
label = None
icon = None
color = None
order = 0
log = logging.getLogger("InventoryAction")
log.propagate = True
@staticmethod
def is_compatible(container):
"""Override function in a custom class
This method is specifically used to ensure the action can operate on
the container.
Args:
container(dict): the data of a loaded asset, see host.ls()
Returns:
bool
"""
return bool(container.get("objectName"))
def process(self, containers):
"""Override function in a custom class
This method will receive all containers even those which are
incompatible. It is advised to create a small filter along the lines
of this example:
valid_containers = filter(self.is_compatible(c) for c in containers)
The return value will need to be a True-ish value to trigger
the data_changed signal in order to refresh the view.
You can return a list of container names to trigger GUI to select
treeview items.
You can return a dict to carry extra GUI options. For example:
{
"objectNames": [container names...],
"options": {"mode": "toggle",
"clear": False}
}
Currently workable GUI options are:
- clear (bool): Clear current selection before selecting by action.
Default `True`.
- mode (str): selection mode, use one of these:
"select", "deselect", "toggle". Default is "select".
Args:
containers (list): list of dictionaries
Return:
bool, list or dict
"""
return True
@classmethod
def filepath_from_context(cls, context):
return get_representation_path_from_context(context)
def discover_inventory_actions():
actions = discover(InventoryAction)
filtered_actions = []
for action in actions:
if action is not InventoryAction:
filtered_actions.append(action)
return filtered_actions
def register_inventory_action(plugin):
return register_plugin(InventoryAction, plugin)
def deregister_inventory_action(plugin):
deregister_plugin(InventoryAction, plugin)
def register_inventory_action_path(path):
return register_plugin_path(InventoryAction, path)
def deregister_inventory_action_path(path):
return deregister_plugin_path(InventoryAction, path)

View file

@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from .load.utils import get_representation_path_from_context
class LauncherActionSelection:
"""Object helper to pass selection to actions.
@ -390,79 +386,6 @@ class LauncherAction(object):
pass
class InventoryAction(object):
"""A custom action for the scene inventory tool
If registered the action will be visible in the Right Mouse Button menu
under the submenu "Actions".
"""
label = None
icon = None
color = None
order = 0
log = logging.getLogger("InventoryAction")
log.propagate = True
@staticmethod
def is_compatible(container):
"""Override function in a custom class
This method is specifically used to ensure the action can operate on
the container.
Args:
container(dict): the data of a loaded asset, see host.ls()
Returns:
bool
"""
return bool(container.get("objectName"))
def process(self, containers):
"""Override function in a custom class
This method will receive all containers even those which are
incompatible. It is advised to create a small filter along the lines
of this example:
valid_containers = filter(self.is_compatible(c) for c in containers)
The return value will need to be a True-ish value to trigger
the data_changed signal in order to refresh the view.
You can return a list of container names to trigger GUI to select
treeview items.
You can return a dict to carry extra GUI options. For example:
{
"objectNames": [container names...],
"options": {"mode": "toggle",
"clear": False}
}
Currently workable GUI options are:
- clear (bool): Clear current selection before selecting by action.
Default `True`.
- mode (str): selection mode, use one of these:
"select", "deselect", "toggle". Default is "select".
Args:
containers (list): list of dictionaries
Return:
bool, list or dict
"""
return True
@classmethod
def filepath_from_context(cls, context):
return get_representation_path_from_context(context)
# Launcher action
def discover_launcher_actions():
return discover(LauncherAction)
@ -473,30 +396,3 @@ def register_launcher_action(plugin):
def register_launcher_action_path(path):
return register_plugin_path(LauncherAction, path)
# Inventory action
def discover_inventory_actions():
actions = discover(InventoryAction)
filtered_actions = []
for action in actions:
if action is not InventoryAction:
filtered_actions.append(action)
return filtered_actions
def register_inventory_action(plugin):
return register_plugin(InventoryAction, plugin)
def deregister_inventory_action(plugin):
deregister_plugin(InventoryAction, plugin)
def register_inventory_action_path(path):
return register_plugin_path(InventoryAction, path)
def deregister_inventory_action_path(path):
return deregister_plugin_path(InventoryAction, path)

View file

@ -0,0 +1,882 @@
"""API for actions for loader tool.
Even though the api is meant for the loader tool, the api should be possible
to use in a standalone way out of the loader tool.
To use add actions, make sure your addon does inherit from
'IPluginPaths' and implements 'get_loader_action_plugin_paths' which
returns paths to python files with loader actions.
The plugin is used to collect available actions for the given context and to
execute them. Selection is defined with 'LoaderActionSelection' object
that also contains a cache of entities and project anatomy.
Implementing 'get_action_items' allows the plugin to define what actions
are shown and available for the selection. Because for a single selection
can be shown multiple actions with the same action identifier, the action
items also have 'data' attribute which can be used to store additional
data for the action (they have to be json-serializable).
The action is triggered by calling the 'execute_action' method. Which takes
the action identifier, the selection, the additional data from the action
item and form values from the form if any.
Using 'LoaderActionResult' as the output of 'execute_action' can trigger to
show a message in UI or to show an additional form ('ActionForm')
which would retrigger the action with the values from the form on
submitting. That allows handling of multistep actions.
It is also recommended that the plugin does override the 'identifier'
attribute. The identifier has to be unique across all plugins.
Class name is used by default.
The selection wrapper currently supports the following types of entity types:
- version
- representation
It is planned to add 'folder' and 'task' selection in the future.
NOTE: It is possible to trigger 'execute_action' without ever calling
'get_action_items', that can be handy in automations.
The whole logic is wrapped into 'LoaderActionsContext'. It takes care of
the discovery of plugins and wraps the collection and execution of
action items. Method 'execute_action' on context also requires plugin
identifier.
The flow of the logic is (in the loader tool):
1. User selects entities in the UI.
2. Right-click the selected entities.
3. Use 'LoaderActionsContext' to collect items using 'get_action_items'.
4. Show a menu (with submenus) in the UI.
5. If a user selects an action, the action is triggered using
'execute_action'.
5a. If the action returns 'LoaderActionResult', show a 'message' if it is
filled and show a form dialog if 'form' is filled.
5b. If the user submitted the form, trigger the action again with the
values from the form and repeat from 5a.
"""
from __future__ import annotations
import os
import collections
import copy
import logging
from abc import ABC, abstractmethod
import typing
from typing import Optional, Any, Callable
from dataclasses import dataclass
import ayon_api
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import StrEnum, Logger, is_func_signature_supported
from ayon_core.host import AbstractHost
from ayon_core.addon import AddonsManager, IPluginPaths
from ayon_core.settings import get_studio_settings, get_project_settings
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.plugin_discover import discover_plugins
from .structures import ActionForm
if typing.TYPE_CHECKING:
from typing import Union
DataBaseType = Union[str, int, float, bool]
DataType = dict[str, Union[DataBaseType, list[DataBaseType]]]
_PLACEHOLDER = object()
class LoaderSelectedType(StrEnum):
"""Selected entity type."""
# folder = "folder"
# task = "task"
version = "version"
representation = "representation"
class SelectionEntitiesCache:
"""Cache of entities used as helper in the selection wrapper.
It is possible to get entities based on ids with helper methods to get
entities, their parents or their children's entities.
The goal is to avoid multiple API calls for the same entity in multiple
action plugins.
The cache is based on the selected project. Entities are fetched
if are not in cache yet.
"""
def __init__(
self,
project_name: str,
project_entity: Optional[dict[str, Any]] = None,
folders_by_id: Optional[dict[str, dict[str, Any]]] = None,
tasks_by_id: Optional[dict[str, dict[str, Any]]] = None,
products_by_id: Optional[dict[str, dict[str, Any]]] = None,
versions_by_id: Optional[dict[str, dict[str, Any]]] = None,
representations_by_id: Optional[dict[str, dict[str, Any]]] = None,
task_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
product_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
version_ids_by_product_id: Optional[dict[str, set[str]]] = None,
representation_ids_by_version_id: Optional[dict[str, set[str]]] = None,
):
self._project_name = project_name
self._project_entity = project_entity
self._folders_by_id = folders_by_id or {}
self._tasks_by_id = tasks_by_id or {}
self._products_by_id = products_by_id or {}
self._versions_by_id = versions_by_id or {}
self._representations_by_id = representations_by_id or {}
self._task_ids_by_folder_id = task_ids_by_folder_id or {}
self._product_ids_by_folder_id = product_ids_by_folder_id or {}
self._version_ids_by_product_id = version_ids_by_product_id or {}
self._representation_ids_by_version_id = (
representation_ids_by_version_id or {}
)
def get_project(self) -> dict[str, Any]:
"""Get project entity"""
if self._project_entity is None:
self._project_entity = ayon_api.get_project(self._project_name)
return copy.deepcopy(self._project_entity)
def get_folders(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
folder_ids,
self._folders_by_id,
"folder_ids",
ayon_api.get_folders,
)
def get_tasks(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
task_ids,
self._tasks_by_id,
"task_ids",
ayon_api.get_tasks,
)
def get_products(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
product_ids,
self._products_by_id,
"product_ids",
ayon_api.get_products,
)
def get_versions(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
version_ids,
self._versions_by_id,
"version_ids",
ayon_api.get_versions,
)
def get_representations(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
representation_ids,
self._representations_by_id,
"representation_ids",
ayon_api.get_representations,
)
def get_folders_tasks(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._task_ids_by_folder_id,
ayon_api.get_tasks,
)
return self.get_tasks(task_ids)
def get_folders_products(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = self._get_folders_products_ids(folder_ids)
return self.get_products(product_ids)
def get_tasks_versions(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
product_ids = self._get_folders_products_ids(folder_ids)
output = []
for version in self.get_products_versions(product_ids):
task_id = version["taskId"]
if task_id in task_ids:
output.append(version)
return output
def get_products_versions(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = self._fill_parent_children_ids(
product_ids,
"productId",
"product_ids",
self._version_ids_by_product_id,
ayon_api.get_versions,
)
return self.get_versions(version_ids)
def get_versions_representations(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
repre_ids = self._fill_parent_children_ids(
version_ids,
"versionId",
"version_ids",
self._representation_ids_by_version_id,
ayon_api.get_representations,
)
return self.get_representations(repre_ids)
def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
return self.get_folders(folder_ids)
def get_products_folders(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
product["folderId"]
for product in self.get_products(product_ids)
}
return self.get_folders(folder_ids)
def get_versions_products(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = {
version["productId"]
for version in self.get_versions(version_ids)
}
return self.get_products(product_ids)
def get_versions_tasks(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = {
version["taskId"]
for version in self.get_versions(version_ids)
if version["taskId"]
}
return self.get_tasks(task_ids)
def get_representations_versions(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = {
repre["versionId"]
for repre in self.get_representations(representation_ids)
}
return self.get_versions(version_ids)
def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]:
return self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._product_ids_by_folder_id,
ayon_api.get_products,
)
def _fill_parent_children_ids(
self,
entity_ids: set[str],
parent_key: str,
filter_attr: str,
parent_mapping: dict[str, set[str]],
getter: Callable,
) -> set[str]:
if not entity_ids:
return set()
children_ids = set()
missing_ids = set()
for entity_id in entity_ids:
_children_ids = parent_mapping.get(entity_id)
if _children_ids is None:
missing_ids.add(entity_id)
else:
children_ids.update(_children_ids)
if missing_ids:
entities_by_parent_id = collections.defaultdict(set)
for entity in getter(
self._project_name,
fields={"id", parent_key},
**{filter_attr: missing_ids},
):
child_id = entity["id"]
children_ids.add(child_id)
entities_by_parent_id[entity[parent_key]].add(child_id)
for entity_id in missing_ids:
parent_mapping[entity_id] = entities_by_parent_id[entity_id]
return children_ids
def _get_entities(
self,
entity_ids: set[str],
cache_var: dict[str, Any],
filter_arg: str,
getter: Callable,
) -> list[dict[str, Any]]:
if not entity_ids:
return []
output = []
missing_ids: set[str] = set()
for entity_id in entity_ids:
entity = cache_var.get(entity_id)
if entity_id not in cache_var:
missing_ids.add(entity_id)
cache_var[entity_id] = None
elif entity:
output.append(entity)
if missing_ids:
for entity in getter(
self._project_name,
**{filter_arg: missing_ids}
):
output.append(entity)
cache_var[entity["id"]] = entity
return output
class LoaderActionSelection:
"""Selection of entities for loader actions.
Selection tells action plugins what exactly is selected in the tool and
which ids.
Contains entity cache which can be used to get entities by their ids. Or
to get project settings and anatomy.
"""
def __init__(
self,
project_name: str,
selected_ids: set[str],
selected_type: LoaderSelectedType,
*,
project_anatomy: Optional[Anatomy] = None,
project_settings: Optional[dict[str, Any]] = None,
entities_cache: Optional[SelectionEntitiesCache] = None,
):
self._project_name = project_name
self._selected_ids = selected_ids
self._selected_type = selected_type
self._project_anatomy = project_anatomy
self._project_settings = project_settings
if entities_cache is None:
entities_cache = SelectionEntitiesCache(project_name)
self._entities_cache = entities_cache
def get_entities_cache(self) -> SelectionEntitiesCache:
return self._entities_cache
def get_project_name(self) -> str:
return self._project_name
def get_selected_ids(self) -> set[str]:
return set(self._selected_ids)
def get_selected_type(self) -> str:
return self._selected_type
def get_project_settings(self) -> dict[str, Any]:
if self._project_settings is None:
self._project_settings = get_project_settings(self._project_name)
return copy.deepcopy(self._project_settings)
def get_project_anatomy(self) -> Anatomy:
if self._project_anatomy is None:
self._project_anatomy = Anatomy(
self._project_name,
project_entity=self.get_entities_cache().get_project(),
)
return self._project_anatomy
project_name = property(get_project_name)
selected_ids = property(get_selected_ids)
selected_type = property(get_selected_type)
project_settings = property(get_project_settings)
project_anatomy = property(get_project_anatomy)
entities = property(get_entities_cache)
# --- Helper methods ---
def versions_selected(self) -> bool:
"""Selected entity type is version.
Returns:
bool: True if selected entity type is version.
"""
return self._selected_type == LoaderSelectedType.version
def representations_selected(self) -> bool:
"""Selected entity type is representation.
Returns:
bool: True if selected entity type is representation.
"""
return self._selected_type == LoaderSelectedType.representation
def get_selected_version_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected version entities.
An empty list is returned if 'version' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected version entities.
"""
if self.versions_selected():
return self.entities.get_versions(self.selected_ids)
return []
def get_selected_representation_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected representation entities.
An empty list is returned if 'representation' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected representation entities.
"""
if self.representations_selected():
return self.entities.get_representations(self.selected_ids)
return []
@dataclass
class LoaderActionItem:
"""Item of loader action.
Action plugins return these items as possible actions to run for a given
context.
Because the action item can be related to a specific entity
and not the whole selection, they also have to define the entity type
and ids to be executed on.
Attributes:
label (str): Text shown in UI.
order (int): Order of the action in UI.
group_label (Optional[str]): Label of the group to which the action
belongs.
icon (Optional[dict[str, Any]): Icon definition.
data (Optional[DataType]): Action item data.
identifier (Optional[str]): Identifier of the plugin which
created the action item. Is filled automatically. Is not changed
if is filled -> can lead to different plugin.
"""
label: str
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
data: Optional[DataType] = None
# Is filled automatically
identifier: str = None
@dataclass
class LoaderActionResult:
"""Result of loader action execution.
Attributes:
message (Optional[str]): Message to show in UI.
success (bool): If the action was successful. Affects color of
the message.
form (Optional[ActionForm]): Form to show in UI.
form_values (Optional[dict[str, Any]]): Values for the form. Can be
used if the same form is re-shown e.g. because a user forgot to
fill a required field.
"""
message: Optional[str] = None
success: bool = True
form: Optional[ActionForm] = None
form_values: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
form = self.form
if form is not None:
form = form.to_json_data()
return {
"message": self.message,
"success": self.success,
"form": form,
"form_values": self.form_values,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult":
form = data["form"]
if form is not None:
data["form"] = ActionForm.from_json_data(form)
return LoaderActionResult(**data)
class LoaderActionPlugin(ABC):
"""Plugin for loader actions.
Plugin is responsible for getting action items and executing actions.
"""
_log: Optional[logging.Logger] = None
enabled: bool = True
def __init__(self, context: "LoaderActionsContext") -> None:
self._context = context
self.apply_settings(context.get_studio_settings())
def apply_settings(self, studio_settings: dict[str, Any]) -> None:
"""Apply studio settings to the plugin.
Args:
studio_settings (dict[str, Any]): Studio settings.
"""
pass
@property
def log(self) -> logging.Logger:
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@property
def identifier(self) -> str:
"""Identifier of the plugin.
Returns:
str: Plugin identifier.
"""
return self.__class__.__name__
@property
def host_name(self) -> Optional[str]:
"""Name of the current host."""
return self._context.get_host_name()
@abstractmethod
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Action items for the selection.
Args:
selection (LoaderActionSelection): Selection.
Returns:
list[LoaderActionItem]: Action items.
"""
pass
@abstractmethod
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Execute an action.
Args:
selection (LoaderActionSelection): Selection wrapper. Can be used
to get entities or get context of original selection.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Attribute values.
Returns:
Optional[LoaderActionResult]: Result of the action execution.
"""
pass
class LoaderActionsContext:
"""Wrapper for loader actions and their logic.
Takes care about the public api of loader actions and internal logic like
discovery and initialization of plugins.
"""
def __init__(
self,
studio_settings: Optional[dict[str, Any]] = None,
addons_manager: Optional[AddonsManager] = None,
host: Optional[AbstractHost] = _PLACEHOLDER,
) -> None:
self._log = Logger.get_logger(self.__class__.__name__)
self._addons_manager = addons_manager
self._host = host
# Attributes that are re-cached on reset
self._studio_settings = studio_settings
self._plugins = None
def reset(
self, studio_settings: Optional[dict[str, Any]] = None
) -> None:
"""Reset context cache.
Reset plugins and studio settings to reload them.
Notes:
Does not reset the cache of AddonsManger because there should not
be a reason to do so.
"""
self._studio_settings = studio_settings
self._plugins = None
def get_addons_manager(self) -> AddonsManager:
if self._addons_manager is None:
self._addons_manager = AddonsManager(
settings=self.get_studio_settings()
)
return self._addons_manager
def get_host(self) -> Optional[AbstractHost]:
"""Get current host integration.
Returns:
Optional[AbstractHost]: Host integration. Can be None if host
integration is not registered -> probably not used in the
host integration process.
"""
if self._host is _PLACEHOLDER:
from ayon_core.pipeline import registered_host
self._host = registered_host()
return self._host
def get_host_name(self) -> Optional[str]:
host = self.get_host()
if host is None:
return None
return host.name
def get_studio_settings(self) -> dict[str, Any]:
if self._studio_settings is None:
self._studio_settings = get_studio_settings()
return copy.deepcopy(self._studio_settings)
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Collect action items from all plugins for given selection.
Args:
selection (LoaderActionSelection): Selection wrapper.
"""
output = []
for plugin_id, plugin in self._get_plugins().items():
try:
for action_item in plugin.get_action_items(selection):
if action_item.identifier is None:
action_item.identifier = plugin_id
output.append(action_item)
except Exception:
self._log.warning(
"Failed to get action items for"
f" plugin '{plugin.identifier}'",
exc_info=True,
)
return output
def execute_action(
self,
identifier: str,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Trigger action execution.
Args:
identifier (str): Identifier of the plugin.
selection (LoaderActionSelection): Selection wrapper. Can be used
to get what is selected in UI and to get access to entity
cache.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Form values related to action.
Usually filled if action returned response with form.
"""
plugins_by_id = self._get_plugins()
plugin = plugins_by_id[identifier]
return plugin.execute_action(
selection,
data,
form_values,
)
def _get_plugins(self) -> dict[str, LoaderActionPlugin]:
if self._plugins is None:
host_name = self.get_host_name()
addons_manager = self.get_addons_manager()
all_paths = [
os.path.join(AYON_CORE_ROOT, "plugins", "loader")
]
for addon in addons_manager.addons:
if not isinstance(addon, IPluginPaths):
continue
try:
if is_func_signature_supported(
addon.get_loader_action_plugin_paths,
host_name
):
paths = addon.get_loader_action_plugin_paths(
host_name
)
else:
paths = addon.get_loader_action_plugin_paths()
except Exception:
self._log.warning(
"Failed to get plugin paths for addon",
exc_info=True
)
continue
if paths:
all_paths.extend(paths)
result = discover_plugins(LoaderActionPlugin, all_paths)
result.log_report()
plugins = {}
for cls in result.plugins:
try:
plugin = cls(self)
if not plugin.enabled:
continue
plugin_id = plugin.identifier
if plugin_id not in plugins:
plugins[plugin_id] = plugin
continue
self._log.warning(
f"Duplicated plugins identifier found '{plugin_id}'."
)
except Exception:
self._log.warning(
f"Failed to initialize plugin '{cls.__name__}'",
exc_info=True,
)
self._plugins = plugins
return self._plugins
class LoaderSimpleActionPlugin(LoaderActionPlugin):
"""Simple action plugin.
This action will show exactly one action item defined by attributes
on the class.
Attributes:
label: Label of the action item.
order: Order of the action item.
group_label: Label of the group to which the action belongs.
icon: Icon definition shown next to label.
"""
label: Optional[str] = None
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
@abstractmethod
def is_compatible(self, selection: LoaderActionSelection) -> bool:
"""Check if plugin is compatible with selection.
Args:
selection (LoaderActionSelection): Selection information.
Returns:
bool: True if plugin is compatible with selection.
"""
pass
@abstractmethod
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Process action based on selection.
Args:
selection (LoaderActionSelection): Selection information.
form_values (dict[str, Any]): Values from a form if there are any.
Returns:
Optional[LoaderActionResult]: Result of the action.
"""
pass
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
if self.is_compatible(selection):
label = self.label or self.__class__.__name__
return [
LoaderActionItem(
label=label,
order=self.order,
group_label=self.group_label,
icon=self.icon,
)
]
return []
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
return self.execute_simple_action(selection, form_values)

View file

@ -0,0 +1,60 @@
from dataclasses import dataclass
from typing import Optional, Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
serialize_attr_defs,
deserialize_attr_defs,
)
@dataclass
class ActionForm:
"""Form for loader action.
If an action needs to collect information from a user before or during of
the action execution, it can return a response with a form. When the
form is submitted, a new execution of the action is triggered.
It is also possible to just show a label message without the submit
button to make sure the user has seen the message.
Attributes:
title (str): Title of the form -> title of the window.
fields (list[AbstractAttrDef]): Fields of the form.
submit_label (Optional[str]): Label of the submit button. Is hidden
if is set to None.
submit_icon (Optional[dict[str, Any]]): Icon definition of the submit
button.
cancel_label (Optional[str]): Label of the cancel button. Is hidden
if is set to None. User can still close the window tho.
cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel
button.
"""
title: str
fields: list[AbstractAttrDef]
submit_label: Optional[str] = "Submit"
submit_icon: Optional[dict[str, Any]] = None
cancel_label: Optional[str] = "Cancel"
cancel_icon: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
fields = self.fields
if fields is not None:
fields = serialize_attr_defs(fields)
return {
"title": self.title,
"fields": fields,
"submit_label": self.submit_label,
"submit_icon": self.submit_icon,
"cancel_label": self.cancel_label,
"cancel_icon": self.cancel_icon,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "ActionForm":
fields = data["fields"]
if fields is not None:
data["fields"] = deserialize_attr_defs(fields)
return cls(**data)

View file

@ -0,0 +1,100 @@
from __future__ import annotations
import uuid
from typing import Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
UILabelDef,
BoolDef,
TextDef,
NumberDef,
EnumDef,
HiddenDef,
)
def webaction_fields_to_attribute_defs(
fields: list[dict[str, Any]]
) -> list[AbstractAttrDef]:
"""Helper function to convert fields definition from webactions form.
Convert form fields to attribute definitions to be able to display them
using attribute definitions.
Args:
fields (list[dict[str, Any]]): Fields from webaction form.
Returns:
list[AbstractAttrDef]: Converted attribute definitions.
"""
attr_defs = []
for field in fields:
field_type = field["type"]
attr_def = None
if field_type == "label":
label = field.get("value")
if label is None:
label = field.get("text")
attr_def = UILabelDef(
label, key=uuid.uuid4().hex
)
elif field_type == "boolean":
value = field["value"]
if isinstance(value, str):
value = value.lower() == "true"
attr_def = BoolDef(
field["name"],
default=value,
label=field.get("label"),
)
elif field_type == "text":
attr_def = TextDef(
field["name"],
default=field.get("value"),
label=field.get("label"),
placeholder=field.get("placeholder"),
multiline=field.get("multiline", False),
regex=field.get("regex"),
# syntax=field["syntax"],
)
elif field_type in ("integer", "float"):
value = field.get("value")
if isinstance(value, str):
if field_type == "integer":
value = int(value)
else:
value = float(value)
attr_def = NumberDef(
field["name"],
default=value,
label=field.get("label"),
decimals=0 if field_type == "integer" else 5,
# placeholder=field.get("placeholder"),
minimum=field.get("min"),
maximum=field.get("max"),
)
elif field_type in ("select", "multiselect"):
attr_def = EnumDef(
field["name"],
items=field["options"],
default=field.get("value"),
label=field.get("label"),
multiselection=field_type == "multiselect",
)
elif field_type == "hidden":
attr_def = HiddenDef(
field["name"],
default=field.get("value"),
)
if attr_def is None:
print(f"Unknown config field type: {field_type}")
attr_def = UILabelDef(
f"Unknown field type '{field_type}",
key=uuid.uuid4().hex
)
attr_defs.append(attr_def)
return attr_defs

View file

@ -7,6 +7,7 @@ import platform
import tempfile
import warnings
from copy import deepcopy
from dataclasses import dataclass
import ayon_api
@ -26,6 +27,18 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy
log = Logger.get_logger(__name__)
@dataclass
class ConfigData:
"""OCIO Config to use in a certain context.
When enabled and no path/template are set, it will be considered invalid
and will error on OCIO path not found. Enabled must be False to explicitly
allow OCIO to be disabled."""
path: str = ""
template: str = ""
enabled: bool = True
class CachedData:
remapping = {}
has_compatible_ocio_package = None
@ -710,7 +723,7 @@ def _get_config_path_from_profile_data(
template_data (dict[str, Any]): Template data.
Returns:
dict[str, str]: Config data with path and template.
ConfigData: Config data with path and template.
"""
template = profile[profile_type]
result = StringTemplate.format_strict_template(
@ -719,12 +732,12 @@ def _get_config_path_from_profile_data(
normalized_path = str(result.normalized())
if not os.path.exists(normalized_path):
log.warning(f"Path was not found '{normalized_path}'.")
return None
return ConfigData() # Return invalid config data
return {
"path": normalized_path,
"template": template
}
return ConfigData(
path=normalized_path,
template=template
)
def _get_global_config_data(
@ -735,7 +748,7 @@ def _get_global_config_data(
imageio_global,
folder_id,
log,
):
) -> ConfigData:
"""Get global config data.
Global config from core settings is using profiles that are based on
@ -759,8 +772,7 @@ def _get_global_config_data(
log (logging.Logger): Logger object.
Returns:
Union[dict[str, str], None]: Config data with path and template
or None.
ConfigData: Config data with path and template.
"""
task_name = task_type = None
@ -779,12 +791,14 @@ def _get_global_config_data(
)
if profile is None:
log.info(f"No config profile matched filters {str(filter_values)}")
return None
return ConfigData(enabled=False)
profile_type = profile["type"]
if profile_type in ("builtin_path", "custom_path"):
if profile_type in {"builtin_path", "custom_path"}:
return _get_config_path_from_profile_data(
profile, profile_type, template_data)
elif profile_type == "disabled":
return ConfigData(enabled=False)
# TODO decide if this is the right name for representation
repre_name = "ocioconfig"
@ -798,7 +812,7 @@ def _get_global_config_data(
"Colorspace OCIO config path cannot be set. "
"Profile is set to published product but `Product name` is empty."
)
return None
return ConfigData()
folder_info = template_data.get("folder")
if not folder_info:
@ -819,7 +833,7 @@ def _get_global_config_data(
)
if not folder_entity:
log.warning(f"Folder entity '{folder_path}' was not found..")
return None
return ConfigData()
folder_id = folder_entity["id"]
product_entities_by_name = {
@ -855,7 +869,7 @@ def _get_global_config_data(
log.info(
f"Product '{product_name}' does not have available any versions."
)
return None
return ConfigData()
# Find 'ocioconfig' representation entity
repre_entity = ayon_api.get_representation_by_name(
@ -868,15 +882,15 @@ def _get_global_config_data(
f"Representation '{repre_name}'"
f" not found on product '{product_name}'."
)
return None
return ConfigData()
path = get_representation_path_with_anatomy(repre_entity, anatomy)
template = repre_entity["attrib"]["template"]
return {
"path": path,
"template": template,
}
return ConfigData(
path=path,
template=template
)
def get_imageio_config_preset(
@ -1015,13 +1029,19 @@ def get_imageio_config_preset(
host_ocio_config["filepath"], template_data
)
if not config_data:
if not config_data.enabled:
return {} # OCIO management disabled
if not config_data.path:
raise FileExistsError(
"No OCIO config found in settings. It is"
" either missing or there is typo in path inputs"
)
return config_data
return {
"path": config_data.path,
"template": config_data.template,
}
def _get_host_config_data(templates, template_data):

View file

@ -1,4 +1,5 @@
"""Package to handle compatibility checks for pipeline components."""
import ayon_api
def is_product_base_type_supported() -> bool:
@ -13,4 +14,7 @@ def is_product_base_type_supported() -> bool:
bool: True if product base types are supported, False otherwise.
"""
return False
if not hasattr(ayon_api, "is_product_base_type_supported"):
return False
return ayon_api.is_product_base_type_supported()

View file

@ -15,6 +15,7 @@ from typing import (
Any,
Callable,
)
from warnings import warn
import pyblish.logic
import pyblish.api
@ -752,13 +753,13 @@ class CreateContext:
manual_creators = {}
report = discover_creator_plugins(return_report=True)
self.creator_discover_result = report
for creator_class in report.plugins:
if inspect.isabstract(creator_class):
self.log.debug(
"Skipping abstract Creator {}".format(str(creator_class))
)
continue
for creator_class in report.abstract_plugins:
self.log.debug(
"Skipping abstract Creator '%s'",
str(creator_class)
)
for creator_class in report.plugins:
creator_identifier = creator_class.identifier
if creator_identifier in creators:
self.log.warning(
@ -772,19 +773,17 @@ class CreateContext:
creator_class.host_name
and creator_class.host_name != self.host_name
):
self.log.info((
"Creator's host name \"{}\""
" is not supported for current host \"{}\""
).format(creator_class.host_name, self.host_name))
self.log.info(
(
'Creator\'s host name "{}"'
' is not supported for current host "{}"'
).format(creator_class.host_name, self.host_name)
)
continue
# TODO report initialization error
try:
creator = creator_class(
project_settings,
self,
self.headless
)
creator = creator_class(project_settings, self, self.headless)
except Exception:
self.log.error(
f"Failed to initialize plugin: {creator_class}",
@ -792,6 +791,19 @@ class CreateContext:
)
continue
if not creator.product_base_type:
message = (
f"Provided creator {creator!r} doesn't have "
"product base type attribute defined. This will be "
"required in future."
)
warn(
message,
DeprecationWarning,
stacklevel=2
)
self.log.warning(message)
if not creator.enabled:
disabled_creators[creator_identifier] = creator
continue
@ -1289,8 +1301,12 @@ class CreateContext:
"folderPath": folder_entity["path"],
"task": task_entity["name"] if task_entity else None,
"productType": creator.product_type,
# Add product base type if supported. Fallback to product type
"productBaseType": (
creator.product_base_type or creator.product_type),
"variant": variant
}
if active is not None:
if not isinstance(active, bool):
self.log.warning(

View file

@ -1,20 +1,21 @@
# -*- coding: utf-8 -*-
import os
import copy
import collections
from typing import TYPE_CHECKING, Optional, Dict, Any
"""Creator plugins for the create process."""
from __future__ import annotations
import collections
import copy
import os
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Optional
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
deregister_plugin,
deregister_plugin_path,
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
@ -23,6 +24,7 @@ from .structures import CreatedInstance
if TYPE_CHECKING:
from ayon_core.lib import AbstractAttrDef
# Avoid cyclic imports
from .context import CreateContext, UpdateData # noqa: F401
@ -66,7 +68,6 @@ class ProductConvertorPlugin(ABC):
Returns:
logging.Logger: Logger with name of the plugin.
"""
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@ -82,9 +83,8 @@ class ProductConvertorPlugin(ABC):
Returns:
str: Converted identifier unique for all converters in host.
"""
pass
"""
@abstractmethod
def find_instances(self):
@ -94,14 +94,10 @@ class ProductConvertorPlugin(ABC):
convert.
"""
pass
@abstractmethod
def convert(self):
"""Conversion code."""
pass
@property
def create_context(self):
"""Quick access to create context.
@ -109,7 +105,6 @@ class ProductConvertorPlugin(ABC):
Returns:
CreateContext: Context which initialized the plugin.
"""
return self._create_context
@property
@ -122,7 +117,6 @@ class ProductConvertorPlugin(ABC):
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self._create_context.collection_shared_data
def add_convertor_item(self, label):
@ -131,12 +125,10 @@ class ProductConvertorPlugin(ABC):
Args:
label (str): Label of item which will show in UI.
"""
self._create_context.add_convertor_item(self.identifier, label)
def remove_convertor_item(self):
"""Remove legacy item from create context when conversion finished."""
self._create_context.remove_convertor_item(self.identifier)
@ -154,7 +146,14 @@ class BaseCreator(ABC):
project_settings (dict[str, Any]): Project settings.
create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode.
"""
# Attribute 'skip_discovery' is used during discovery phase to skip
# plugins, which can be used to mark base plugins that should not be
# considered as plugins "to use". The discovery logic does NOT use
# the attribute value from parent classes. Each base class has to define
# the attribute again.
skip_discovery = True
# Label shown in UI
label = None
@ -219,7 +218,6 @@ class BaseCreator(ABC):
Returns:
Optional[dict[str, Any]]: Settings values or None.
"""
settings = project_settings.get(category_name)
if not settings:
return None
@ -265,7 +263,6 @@ class BaseCreator(ABC):
Args:
project_settings (dict[str, Any]): Project settings.
"""
settings_category = self.settings_category
if not settings_category:
return
@ -277,18 +274,17 @@ class BaseCreator(ABC):
project_settings, settings_category, settings_name
)
if settings is None:
self.log.debug("No settings found for {}".format(cls_name))
self.log.debug(f"No settings found for {cls_name}")
return
for key, value in settings.items():
# Log out attributes that are not defined on plugin object
# - those may be potential dangerous typos in settings
if not hasattr(self, key):
self.log.debug((
"Applying settings to unknown attribute '{}' on '{}'."
).format(
self.log.debug(
"Applying settings to unknown attribute '%s' on '%s'.",
key, cls_name
))
)
setattr(self, key, value)
def register_callbacks(self):
@ -297,23 +293,39 @@ class BaseCreator(ABC):
Default implementation does nothing. It can be overridden to register
callbacks for creator.
"""
pass
@property
def identifier(self):
"""Identifier of creator (must be unique).
Default implementation returns plugin's product type.
"""
Default implementation returns plugin's product base type,
or falls back to product type if product base type is not set.
return self.product_type
"""
identifier = self.product_base_type
if not identifier:
identifier = self.product_type
return identifier
@property
@abstractmethod
def product_type(self):
"""Family that plugin represents."""
pass
@property
def product_base_type(self) -> Optional[str]:
"""Base product type that plugin represents.
Todo (antirotor): This should be required in future - it
should be made abstract then.
Returns:
Optional[str]: Base product type that plugin represents.
If not set, it is assumed that the creator plugin is obsolete
and does not support product base type.
"""
return None
@property
def project_name(self):
@ -322,7 +334,6 @@ class BaseCreator(ABC):
Returns:
str: Name of a project.
"""
return self.create_context.project_name
@property
@ -332,7 +343,6 @@ class BaseCreator(ABC):
Returns:
Anatomy: Project anatomy object.
"""
return self.create_context.project_anatomy
@property
@ -344,13 +354,14 @@ class BaseCreator(ABC):
Default implementation use attributes in this order:
- 'group_label' -> 'label' -> 'identifier'
Keep in mind that 'identifier' use 'product_type' by default.
Keep in mind that 'identifier' uses 'product_base_type' by default.
Returns:
str: Group label that can be used for grouping of instances in UI.
Group label can be overridden by instance itself.
"""
Group label can be overridden by the instance itself.
"""
if self._cached_group_label is None:
label = self.identifier
if self.group_label:
@ -367,7 +378,6 @@ class BaseCreator(ABC):
Returns:
logging.Logger: Logger with name of the plugin.
"""
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@ -376,7 +386,8 @@ class BaseCreator(ABC):
self,
product_name: str,
data: Dict[str, Any],
product_type: Optional[str] = None
product_type: Optional[str] = None,
product_base_type: Optional[str] = None
) -> CreatedInstance:
"""Create instance and add instance to context.
@ -385,6 +396,8 @@ class BaseCreator(ABC):
data (Dict[str, Any]): Instance data.
product_type (Optional[str]): Product type, object attribute
'product_type' is used if not passed.
product_base_type (Optional[str]): Product base type, object
attribute 'product_base_type' is used if not passed.
Returns:
CreatedInstance: Created instance.
@ -392,11 +405,16 @@ class BaseCreator(ABC):
"""
if product_type is None:
product_type = self.product_type
if not product_base_type and not self.product_base_type:
product_base_type = product_type
instance = CreatedInstance(
product_type,
product_name,
data,
product_type=product_type,
product_name=product_name,
data=data,
creator=self,
product_base_type=product_base_type,
)
self._add_instance_to_context(instance)
return instance
@ -412,7 +430,6 @@ class BaseCreator(ABC):
Args:
instance (CreatedInstance): New created instance.
"""
self.create_context.creator_adds_instance(instance)
def _remove_instance_from_context(self, instance):
@ -425,7 +442,6 @@ class BaseCreator(ABC):
Args:
instance (CreatedInstance): Instance which should be removed.
"""
self.create_context.creator_removed_instance(instance)
@abstractmethod
@ -437,8 +453,6 @@ class BaseCreator(ABC):
implementation
"""
pass
@abstractmethod
def collect_instances(self):
"""Collect existing instances related to this creator plugin.
@ -464,8 +478,6 @@ class BaseCreator(ABC):
```
"""
pass
@abstractmethod
def update_instances(self, update_list):
"""Store changes of existing instances so they can be recollected.
@ -475,8 +487,6 @@ class BaseCreator(ABC):
contain changed instance and it's changes.
"""
pass
@abstractmethod
def remove_instances(self, instances):
"""Method called on instance removal.
@ -489,14 +499,11 @@ class BaseCreator(ABC):
removed.
"""
pass
def get_icon(self):
"""Icon of creator (product type).
Can return path to image file or awesome icon name.
"""
return self.icon
def get_dynamic_data(
@ -512,19 +519,18 @@ class BaseCreator(ABC):
These may be dynamically created based on current context of workfile.
"""
return {}
def get_product_name(
self,
project_name,
folder_entity,
task_entity,
variant,
host_name=None,
instance=None,
project_entity=None,
):
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
variant: str,
host_name: Optional[str] = None,
instance: Optional[CreatedInstance] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> str:
"""Return product name for passed context.
Method is also called on product name update. In that case origin
@ -546,11 +552,6 @@ class BaseCreator(ABC):
if host_name is None:
host_name = self.create_context.host_name
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
dynamic_data = self.get_dynamic_data(
project_name,
folder_entity,
@ -566,11 +567,12 @@ class BaseCreator(ABC):
return get_product_name(
project_name,
task_name,
task_type,
host_name,
self.product_type,
variant,
folder_entity=folder_entity,
task_entity=task_entity,
product_base_type=self.product_base_type,
product_type=self.product_type,
host_name=host_name,
variant=variant,
dynamic_data=dynamic_data,
project_settings=self.project_settings,
project_entity=project_entity,
@ -583,15 +585,15 @@ class BaseCreator(ABC):
and values are stored to metadata for future usage and for publishing
purposes.
NOTE:
Convert method should be implemented which should care about updating
keys/values when plugin attributes change.
Note:
Convert method should be implemented which should care about
updating keys/values when plugin attributes change.
Returns:
list[AbstractAttrDef]: Attribute definitions that can be tweaked
for created instance.
"""
"""
return self.instance_attr_defs
def get_attr_defs_for_instance(self, instance):
@ -614,12 +616,10 @@ class BaseCreator(ABC):
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self.create_context.collection_shared_data
def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None):
"""Set path to thumbnail for instance."""
self.create_context.thumbnail_paths_by_instance_id[instance_id] = (
thumbnail_path
)
@ -640,7 +640,6 @@ class BaseCreator(ABC):
Returns:
dict[str, int]: Next versions by instance id.
"""
return get_next_versions_for_instances(
self.create_context.project_name, instances
)
@ -651,7 +650,7 @@ class Creator(BaseCreator):
Creation requires prepared product name and instance data.
"""
skip_discovery = True
# GUI Purposes
# - default_variants may not be used if `get_default_variants`
# is overridden
@ -707,7 +706,6 @@ class Creator(BaseCreator):
int: Order in which is creator shown (less == earlier). By default
is using Creator's 'order' or processing.
"""
return self.order
@abstractmethod
@ -722,11 +720,9 @@ class Creator(BaseCreator):
pre_create_data(dict): Data based on pre creation attributes.
Those may affect how creator works.
"""
# instance = CreatedInstance(
# self.product_type, product_name, instance_data
# )
pass
def get_description(self):
"""Short description of product type and plugin.
@ -734,7 +730,6 @@ class Creator(BaseCreator):
Returns:
str: Short description of product type.
"""
return self.description
def get_detail_description(self):
@ -745,7 +740,6 @@ class Creator(BaseCreator):
Returns:
str: Detailed description of product type for artist.
"""
return self.detailed_description
def get_default_variants(self):
@ -759,7 +753,6 @@ class Creator(BaseCreator):
Returns:
list[str]: Whisper variants for user input.
"""
return copy.deepcopy(self.default_variants)
def get_default_variant(self, only_explicit=False):
@ -779,7 +772,6 @@ class Creator(BaseCreator):
Returns:
str: Variant value.
"""
if only_explicit or self._default_variant:
return self._default_variant
@ -800,7 +792,6 @@ class Creator(BaseCreator):
Returns:
str: Variant value.
"""
return self.get_default_variant()
def _set_default_variant_wrap(self, variant):
@ -812,7 +803,6 @@ class Creator(BaseCreator):
Args:
variant (str): New default variant value.
"""
self._default_variant = variant
default_variant = property(
@ -949,6 +939,8 @@ class Creator(BaseCreator):
class HiddenCreator(BaseCreator):
skip_discovery = True
@abstractmethod
def create(self, instance_data, source_data):
pass
@ -959,10 +951,10 @@ class AutoCreator(BaseCreator):
Can be used e.g. for `workfile`.
"""
skip_discovery = True
def remove_instances(self, instances):
"""Skip removal."""
pass
def discover_creator_plugins(*args, **kwargs):
@ -1020,7 +1012,6 @@ def cache_and_get_instances(creator, shared_key, list_instances_func):
dict[str, dict[str, Any]]: Cached instances by creator identifier from
result of passed function.
"""
if shared_key not in creator.collection_shared_data:
value = collections.defaultdict(list)
for instance in list_instances_func():

View file

@ -1,24 +1,38 @@
"""Functions for handling product names."""
from __future__ import annotations
import warnings
from functools import wraps
from typing import Any, Optional, Union, overload
from warnings import warn
import ayon_api
from ayon_core.lib import (
StringTemplate,
filter_profiles,
prepare_template_data,
Logger,
is_func_signature_supported,
)
from ayon_core.lib.path_templates import TemplateResult
from ayon_core.settings import get_project_settings
from .constants import DEFAULT_PRODUCT_TEMPLATE
from .exceptions import TaskNotSetError, TemplateFillError
log = Logger.get_logger(__name__)
def get_product_name_template(
project_name,
product_type,
task_name,
task_type,
host_name,
default_template=None,
project_settings=None
):
project_name: str,
product_type: str,
task_name: Optional[str],
task_type: Optional[str],
host_name: str,
default_template: Optional[str] = None,
project_settings: Optional[dict[str, Any]] = None,
product_base_type: Optional[str] = None
) -> str:
"""Get product name template based on passed context.
Args:
@ -26,26 +40,32 @@ def get_product_name_template(
product_type (str): Product type for which the product name is
calculated.
host_name (str): Name of host in which the product name is calculated.
task_name (str): Name of task in which context the product is created.
task_type (str): Type of task in which context the product is created.
default_template (Union[str, None]): Default template which is used if
task_name (Optional[str]): Name of task in which context the
product is created.
task_type (Optional[str]): Type of task in which context the
product is created.
default_template (Optional[str]): Default template which is used if
settings won't find any matching possibility. Constant
'DEFAULT_PRODUCT_TEMPLATE' is used if not defined.
project_settings (Union[Dict[str, Any], None]): Prepared settings for
project_settings (Optional[dict[str, Any]]): Prepared settings for
project. Settings are queried if not passed.
"""
product_base_type (Optional[str]): Base type of product.
Returns:
str: Product name template.
"""
if project_settings is None:
project_settings = get_project_settings(project_name)
tools_settings = project_settings["core"]["tools"]
profiles = tools_settings["creator"]["product_name_profiles"]
filtering_criteria = {
"product_base_types": product_base_type or product_type,
"product_types": product_type,
"hosts": host_name,
"tasks": task_name,
"task_types": task_type
"host_names": host_name,
"task_names": task_name,
"task_types": task_type,
}
matching_profile = filter_profiles(profiles, filtering_criteria)
template = None
if matching_profile:
@ -69,6 +89,214 @@ def get_product_name_template(
return template
def _get_product_name_old(
project_name: str,
task_name: Optional[str],
task_type: Optional[str],
host_name: str,
product_type: str,
variant: str,
default_template: Optional[str] = None,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
product_type_filter: Optional[str] = None,
project_entity: Optional[dict[str, Any]] = None,
product_base_type: Optional[str] = None,
) -> TemplateResult:
warnings.warn(
"Used deprecated 'task_name' and 'task_type' arguments."
" Please use new signature with 'folder_entity' and 'task_entity'.",
DeprecationWarning,
stacklevel=2
)
if not product_type:
return StringTemplate("").format({})
template = get_product_name_template(
project_name=project_name,
product_type=product_type_filter or product_type,
task_name=task_name,
task_type=task_type,
host_name=host_name,
default_template=default_template,
project_settings=project_settings,
product_base_type=product_base_type,
)
template_low = template.lower()
# Simple check of task name existence for template with {task[name]} in
if not task_name and "{task" in template_low:
raise TaskNotSetError()
task_value = {
"name": task_name,
"type": task_type,
}
if "{task}" in template_low:
task_value = task_name
# NOTE this is message for TDs and Admins -> not really for users
# TODO validate this in settings and not allow it
log.warning(
"Found deprecated task key '{task}' in product name template."
" Please use '{task[name]}' instead."
)
elif "{task[short]}" in template_low:
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_value["short"] = task_short
if not product_base_type and "{product[basetype]}" in template.lower():
warn(
"You have Product base type in product name template, "
"but it is not provided by the creator, please update your "
"creation code to include it. It will be required in "
"the future.",
DeprecationWarning,
stacklevel=2)
fill_pairs: dict[str, Union[str, dict[str, str]]] = {
"variant": variant,
"family": product_type,
"task": task_value,
"product": {
"type": product_type,
"basetype": product_base_type or product_type,
}
}
if dynamic_data:
# Dynamic data may override default values
for key, value in dynamic_data.items():
fill_pairs[key] = value
try:
return StringTemplate.format_strict_template(
template=template,
data=prepare_template_data(fill_pairs)
)
except KeyError as exp:
msg = (
f"Value for {exp} key is missing in template '{template}'."
f" Available values are {fill_pairs}"
)
raise TemplateFillError(msg) from exp
def _backwards_compatibility_product_name(func):
"""Helper to decide which variant of 'get_product_name' to use.
The old version expected 'task_name' and 'task_type' arguments. The new
version expects 'folder_entity' and 'task_entity' arguments instead.
The function is also marked with an attribute 'version' so other addons
can check if the function is using the new signature or is using
the old signature. That should allow addons to adapt to new signature.
>>> if getattr(get_product_name, "use_entities", None):
>>> # New signature is used
>>> path = get_product_name(project_name, folder_entity, ...)
>>> else:
>>> # Old signature is used
>>> path = get_product_name(project_name, taks_name, ...)
"""
# Add attribute to function to identify it as the new function
# so other addons can easily identify it.
# >>> geattr(get_product_name, "use_entities", False)
setattr(func, "use_entities", True)
@wraps(func)
def inner(*args, **kwargs):
# ---
# Decide which variant of the function is used based on
# passed arguments.
# ---
# Entities in key-word arguments mean that the new function is used
if "folder_entity" in kwargs or "task_entity" in kwargs:
return func(*args, **kwargs)
# Using more than 7 positional arguments is not allowed
# in the new function
if len(args) > 7:
return _get_product_name_old(*args, **kwargs)
if len(args) > 1:
arg_2 = args[1]
# The second argument is a string -> task name
if isinstance(arg_2, str):
return _get_product_name_old(*args, **kwargs)
if is_func_signature_supported(func, *args, **kwargs):
return func(*args, **kwargs)
return _get_product_name_old(*args, **kwargs)
return inner
@overload
def get_product_name(
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
product_base_type: str,
product_type: str,
host_name: str,
variant: str,
*,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
project_entity: Optional[dict[str, Any]] = None,
default_template: Optional[str] = None,
product_base_type_filter: Optional[str] = None,
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Subst name templates are defined in `project_settings/global/tools/creator
/product_name_profiles` where are profiles with host name, product type,
task name and task type filters. If context does not match any profile
then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Args:
project_name (str): Project name.
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
host_name (str): Host name.
product_base_type (str): Product base type.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for
a creator which creates instance.
project_settings (Optional[dict[str, Any]]): Prepared settings
for project. Settings are queried if not passed.
project_entity (Optional[dict[str, Any]]): Project entity used when
task short name is required by template.
default_template (Optional[str]): Default template if any profile does
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
is used if is not passed.
product_base_type_filter (Optional[str]): Use different product base
type for product template filtering. Value of
`product_base_type_filter` is used when not passed.
Returns:
TemplateResult: Product name.
Raises:
TaskNotSetError: If template requires task which is not provided.
TemplateFillError: If filled template contains placeholder key which
is not collected.
"""
@overload
def get_product_name(
project_name,
task_name,
@ -81,25 +309,25 @@ def get_product_name(
project_settings=None,
product_type_filter=None,
project_entity=None,
):
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Subst name templates are defined in `project_settings/global/tools/creator
/product_name_profiles` where are profiles with host name, product type,
task name and task type filters. If context does not match any profile
then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
Product name templates are defined in `project_settings/global/tools
/creator/product_name_profiles` where are profiles with host name,
product type, task name and task type filters. If context does not match
any profile then `DEFAULT_PRODUCT_TEMPLATE` is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Todos:
Find better filtering options to avoid requirement of
argument 'family_filter'.
Deprecated:
This function is using deprecated signature that does not support
folder entity data to be used.
Args:
project_name (str): Project name.
task_name (Union[str, None]): Task name.
task_type (Union[str, None]): Task type.
task_name (Optional[str]): Task name.
task_type (Optional[str]): Task type.
host_name (str): Host name.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
@ -117,7 +345,63 @@ def get_product_name(
task short name is required by template.
Returns:
str: Product name.
TemplateResult: Product name.
"""
pass
@_backwards_compatibility_product_name
def get_product_name(
project_name: str,
folder_entity: dict[str, Any],
task_entity: Optional[dict[str, Any]],
product_base_type: str,
product_type: str,
host_name: str,
variant: str,
*,
dynamic_data: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
project_entity: Optional[dict[str, Any]] = None,
default_template: Optional[str] = None,
product_base_type_filter: Optional[str] = None,
) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings.
Product name templates are defined in `project_settings/global/tools
/creator/product_name_profiles` where are profiles with host name,
product base type, product type, task name and task type filters.
If context does not match any profile then `DEFAULT_PRODUCT_TEMPLATE`
is used as default template.
That's main reason why so many arguments are required to calculate product
name.
Args:
project_name (str): Project name.
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
host_name (str): Host name.
product_base_type (str): Product base type.
product_type (str): Product type.
variant (str): In most of the cases it is user input during creation.
dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for
a creator which creates instance.
project_settings (Optional[dict[str, Any]]): Prepared settings
for project. Settings are queried if not passed.
project_entity (Optional[dict[str, Any]]): Project entity used when
task short name is required by template.
default_template (Optional[str]): Default template if any profile does
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
is used if is not passed.
product_base_type_filter (Optional[str]): Use different product base
type for product template filtering. Value of
`product_base_type_filter` is used when not passed.
Returns:
TemplateResult: Product name.
Raises:
TaskNotSetError: If template requires task which is not provided.
@ -126,47 +410,68 @@ def get_product_name(
"""
if not product_type:
return ""
return StringTemplate("").format({})
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
template = get_product_name_template(
project_name,
product_type_filter or product_type,
task_name,
task_type,
host_name,
project_name=project_name,
product_base_type=product_base_type_filter or product_base_type,
product_type=product_type,
task_name=task_name,
task_type=task_type,
host_name=host_name,
default_template=default_template,
project_settings=project_settings
project_settings=project_settings,
)
# Simple check of task name existence for template with {task} in
# - missing task should be possible only in Standalone publisher
if not task_name and "{task" in template.lower():
template_low = template.lower()
# Simple check of task name existence for template with {task[name]} in
if not task_name and "{task" in template_low:
raise TaskNotSetError()
task_value = {
"name": task_name,
"type": task_type,
}
if "{task}" in template.lower():
if "{task}" in template_low:
task_value = task_name
# NOTE this is message for TDs and Admins -> not really for users
# TODO validate this in settings and not allow it
log.warning(
"Found deprecated task key '{task}' in product name template."
" Please use '{task[name]}' instead."
)
elif "{task[short]}" in template.lower():
elif "{task[short]}" in template_low:
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
task["name"]: task
for task in project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_value["short"] = task_short
fill_pairs = {
"variant": variant,
# TODO We should stop support 'family' key.
"family": product_type,
"task": task_value,
"product": {
"type": product_type
"type": product_type,
"basetype": product_base_type,
}
}
if folder_entity:
fill_pairs["folder"] = {
"name": folder_entity["name"],
"type": folder_entity["folderType"],
}
if dynamic_data:
# Dynamic data may override default values
for key, value in dynamic_data.items():
@ -178,7 +483,8 @@ def get_product_name(
data=prepare_template_data(fill_pairs)
)
except KeyError as exp:
raise TemplateFillError(
"Value for {} key is missing in template '{}'."
" Available values are {}".format(str(exp), template, fill_pairs)
msg = (
f"Value for {exp} key is missing in template '{template}'."
f" Available values are {fill_pairs}"
)
raise TemplateFillError(msg)

View file

@ -11,6 +11,8 @@ from ayon_core.lib.attribute_definitions import (
serialize_attr_defs,
deserialize_attr_defs,
)
from ayon_core.pipeline import (
AYON_INSTANCE_ID,
AVALON_INSTANCE_ID,
@ -137,6 +139,7 @@ class AttributeValues:
if value is None:
continue
converted_value = attr_def.convert_value(value)
# QUESTION Could we just use converted value all the time?
if converted_value == value:
self._data[attr_def.key] = value
@ -245,11 +248,11 @@ class AttributeValues:
def _update(self, value):
changes = {}
for key, value in dict(value).items():
if key in self._data and self._data.get(key) == value:
for key, key_value in dict(value).items():
if key in self._data and self._data.get(key) == key_value:
continue
self._data[key] = value
changes[key] = value
self._data[key] = key_value
changes[key] = key_value
return changes
def _pop(self, key, default):
@ -479,6 +482,10 @@ class CreatedInstance:
data (Dict[str, Any]): Data used for filling product name or override
data from already existing instance.
creator (BaseCreator): Creator responsible for instance.
product_base_type (Optional[str]): Product base type that will be
created. If not provided then product base type is taken from
creator plugin. If creator does not have product base type then
deprecation warning is raised.
"""
# Keys that can't be changed or removed from data after loading using
@ -489,6 +496,7 @@ class CreatedInstance:
"id",
"instance_id",
"productType",
"productBaseType",
"creator_identifier",
"creator_attributes",
"publish_attributes"
@ -508,7 +516,13 @@ class CreatedInstance:
data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
product_base_type: Optional[str] = None
):
"""Initialize CreatedInstance."""
# fallback to product type for backward compatibility
if not product_base_type:
product_base_type = creator.product_base_type or product_type
self._creator = creator
creator_identifier = creator.identifier
group_label = creator.get_group_label()
@ -561,6 +575,9 @@ class CreatedInstance:
self._data["id"] = item_id
self._data["productType"] = product_type
self._data["productName"] = product_name
self._data["productBaseType"] = product_base_type
self._data["active"] = data.get("active", True)
self._data["creator_identifier"] = creator_identifier

View file

@ -202,7 +202,8 @@ def is_clip_from_media_sequence(otio_clip):
def remap_range_on_file_sequence(otio_clip, otio_range):
"""
""" Remap the provided range on a file sequence clip.
Args:
otio_clip (otio.schema.Clip): The OTIO clip to check.
otio_range (otio.schema.TimeRange): The trim range to apply.
@ -249,7 +250,11 @@ def remap_range_on_file_sequence(otio_clip, otio_range):
if (
is_clip_from_media_sequence(otio_clip)
and available_range_start_frame == media_ref.start_frame
and conformed_src_in.to_frames() < media_ref.start_frame
# source range should be included in available range from media
# using round instead of conformed_src_in.to_frames() to avoid
# any precision issue with frame rate.
and round(conformed_src_in.value) < media_ref.start_frame
):
media_in = otio.opentime.RationalTime(
0, rate=available_range_rate

View file

@ -253,6 +253,19 @@ def create_skeleton_instance(
"reuseLastVersion": data.get("reuseLastVersion", False),
}
# Pass on the OCIO metadata of what the source display and view are
# so that the farm can correctly set up color management.
if "sceneDisplay" in data and "sceneView" in data:
instance_skeleton_data["sceneDisplay"] = data["sceneDisplay"]
instance_skeleton_data["sceneView"] = data["sceneView"]
elif "colorspaceDisplay" in data and "colorspaceView" in data:
# Backwards compatibility for sceneDisplay and sceneView
instance_skeleton_data["colorspaceDisplay"] = data["colorspaceDisplay"]
instance_skeleton_data["colorspaceView"] = data["colorspaceView"]
if "sourceDisplay" in data and "sourceView" in data:
instance_skeleton_data["sourceDisplay"] = data["sourceDisplay"]
instance_skeleton_data["sourceView"] = data["sourceView"]
if data.get("renderlayer"):
instance_skeleton_data["renderlayer"] = data["renderlayer"]
@ -589,24 +602,7 @@ def create_instances_for_aov(
"""
# we cannot attach AOVs to other products as we consider every
# AOV product of its own.
log = Logger.get_logger("farm_publishing")
additional_color_data = {
"renderProducts": instance.data["renderProducts"],
"colorspaceConfig": instance.data["colorspaceConfig"],
"display": instance.data["colorspaceDisplay"],
"view": instance.data["colorspaceView"]
}
# Get templated path from absolute config path.
anatomy = instance.context.data["anatomy"]
colorspace_template = instance.data["colorspaceConfig"]
try:
additional_color_data["colorspaceTemplate"] = remap_source(
colorspace_template, anatomy)
except ValueError as e:
log.warning(e)
additional_color_data["colorspaceTemplate"] = colorspace_template
# if there are product to attach to and more than one AOV,
# we cannot proceed.
@ -618,6 +614,29 @@ def create_instances_for_aov(
"attaching multiple AOVs or renderable cameras to "
"product is not supported yet.")
additional_data = {
"renderProducts": instance.data["renderProducts"],
}
# Collect color management data if present
colorspace_config = instance.data.get("colorspaceConfig")
if colorspace_config:
additional_data.update({
"colorspaceConfig": colorspace_config,
# Display/View are optional
"display": instance.data.get("sourceDisplay"),
"view": instance.data.get("sourceView")
})
# Get templated path from absolute config path.
anatomy = instance.context.data["anatomy"]
try:
additional_data["colorspaceTemplate"] = remap_source(
colorspace_config, anatomy)
except ValueError as e:
log.warning(e)
additional_data["colorspaceTemplate"] = colorspace_config
# create instances for every AOV we found in expected files.
# NOTE: this is done for every AOV and every render camera (if
# there are multiple renderable cameras in scene)
@ -625,7 +644,7 @@ def create_instances_for_aov(
instance,
skeleton,
aov_filter,
additional_color_data,
additional_data,
skip_integration_repre_list,
do_not_add_review,
frames_to_render
@ -936,16 +955,28 @@ def _create_instances_for_aov(
"stagingDir": staging_dir,
"fps": new_instance.get("fps"),
"tags": ["review"] if preview else [],
"colorspaceData": {
}
if colorspace and additional_data["colorspaceConfig"]:
# Only apply colorspace data if the image has a colorspace
colorspace_data: dict = {
"colorspace": colorspace,
"config": {
"path": additional_data["colorspaceConfig"],
"template": additional_data["colorspaceTemplate"]
},
"display": additional_data["display"],
"view": additional_data["view"]
}
}
# Display/View are optional
display = additional_data.get("display")
if display:
colorspace_data["display"] = display
view = additional_data.get("view")
if view:
colorspace_data["view"] = view
rep["colorspaceData"] = colorspace_data
else:
log.debug("No colorspace data for representation: {}".format(rep))
# support conversion from tiled to scanline
if instance.data.get("convertToScanline"):
@ -1045,7 +1076,9 @@ def get_resources(project_name, version_entity, extension=None):
filtered.append(repre_entity)
representation = filtered[0]
directory = get_representation_path(representation)
directory = get_representation_path(
project_name, representation
)
print("Source: ", directory)
resources = sorted(
[

View file

@ -25,8 +25,8 @@ from .utils import (
get_loader_identifier,
get_loaders_by_name,
get_representation_path_from_context,
get_representation_path,
get_representation_path_from_context,
get_representation_path_with_anatomy,
is_compatible_loader,
@ -85,8 +85,8 @@ __all__ = (
"get_loader_identifier",
"get_loaders_by_name",
"get_representation_path_from_context",
"get_representation_path",
"get_representation_path_from_context",
"get_representation_path_with_anatomy",
"is_compatible_loader",

View file

@ -21,6 +21,13 @@ from .utils import get_representation_path_from_context
class LoaderPlugin(list):
"""Load representation into host application"""
# Attribute 'skip_discovery' is used during discovery phase to skip
# plugins, which can be used to mark base plugins that should not be
# considered as plugins "to use". The discovery logic does NOT use
# the attribute value from parent classes. Each base class has to define
# the attribute again.
skip_discovery = True
product_types: set[str] = set()
product_base_types: Optional[set[str]] = None
representations = set()

View file

@ -1,11 +1,15 @@
from __future__ import annotations
import os
import uuid
import platform
import warnings
import logging
import inspect
import collections
import numbers
from typing import Optional, Union, Any
import copy
from functools import wraps
from typing import Optional, Union, Any, overload
import ayon_api
@ -14,9 +18,8 @@ from ayon_core.lib import (
StringTemplate,
TemplateUnsolved,
)
from ayon_core.pipeline import (
Anatomy,
)
from ayon_core.lib.path_templates import TemplateResult
from ayon_core.pipeline import Anatomy
log = logging.getLogger(__name__)
@ -644,15 +647,15 @@ def get_representation_path_from_context(context):
representation = context["representation"]
project_entity = context.get("project")
root = None
if (
project_entity
and project_entity["name"] != get_current_project_name()
):
anatomy = Anatomy(project_entity["name"])
root = anatomy.roots
return get_representation_path(representation, root)
if project_entity:
project_name = project_entity["name"]
else:
project_name = get_current_project_name()
return get_representation_path(
project_name,
representation,
project_entity=project_entity,
)
def get_representation_path_with_anatomy(repre_entity, anatomy):
@ -671,139 +674,248 @@ def get_representation_path_with_anatomy(repre_entity, anatomy):
anatomy (Anatomy): Project anatomy object.
Returns:
Union[None, TemplateResult]: None if path can't be received
TemplateResult: Resolved representation path.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
return get_representation_path(
anatomy.project_name,
repre_entity,
anatomy=anatomy,
)
def get_representation_path_with_roots(
representation: dict[str, Any],
roots: dict[str, str],
) -> Optional[TemplateResult]:
"""Get filename from representation with custom root.
Args:
representation(dict): Representation entity.
roots (dict[str, str]): Roots to use.
Returns:
Optional[TemplateResult]: Resolved representation path.
"""
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = roots
path = StringTemplate.format_strict_template(
template, context
)
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
return path.normalized()
def _backwards_compatibility_repre_path(func):
"""Wrapper handling backwards compatibility of 'get_representation_path'.
Allows 'get_representation_path' to support old and new signatures of the
function. The old signature supported passing in representation entity
and optional roots. The new signature requires the project name
to be passed. In case custom roots should be used, a dedicated function
'get_representation_path_with_roots' is available.
The wrapper handles passed arguments, and based on kwargs and types
of the arguments will call the function which relates to
the arguments.
The function is also marked with an attribute 'version' so other addons
can check if the function is using the new signature or is using
the old signature. That should allow addons to adapt to new signature.
>>> if getattr(get_representation_path, "version", None) == 2:
>>> path = get_representation_path(project_name, repre_entity)
>>> else:
>>> path = get_representation_path(repre_entity)
The plan to remove backwards compatibility is 1.1.2026.
"""
# Add an attribute to the function so addons can check if the new variant
# of the function is available.
# >>> getattr(get_representation_path, "version", None) == 2
# >>> True
setattr(func, "version", 2)
@wraps(func)
def inner(*args, **kwargs):
from ayon_core.pipeline import get_current_project_name
# Decide which variant of the function based on passed arguments
# will be used.
if args:
arg_1 = args[0]
if isinstance(arg_1, str):
return func(*args, **kwargs)
elif "project_name" in kwargs:
return func(*args, **kwargs)
warnings.warn(
(
"Used deprecated variant of 'get_representation_path'."
" Please change used arguments signature to follow"
" new definition. Will be removed 1.1.2026."
),
DeprecationWarning,
stacklevel=2,
)
# Find out which arguments were passed
if args:
representation = args[0]
else:
representation = kwargs.get("representation")
if len(args) > 1:
roots = args[1]
else:
roots = kwargs.get("root")
if roots is not None:
return get_representation_path_with_roots(
representation, roots
)
project_name = (
representation["context"].get("project", {}).get("name")
)
if project_name is None:
project_name = get_current_project_name()
return func(project_name, representation)
return inner
@overload
def get_representation_path(
representation: dict[str, Any],
root: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""DEPRECATED Get filled representation path.
Use 'get_representation_path' using the new function signature.
Args:
representation (dict[str, Any]): Representation entity.
root (Optional[dict[str, Any]): Roots to fill the path.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@overload
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@_backwards_compatibility_repre_path
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
if anatomy is None:
anatomy = Anatomy(project_name, project_entity=project_entity)
try:
template = repre_entity["attrib"]["template"]
except KeyError:
raise InvalidRepresentationContext((
"Representation document does not"
" contain template in data ('data.template')"
))
except KeyError as exc:
raise InvalidRepresentationContext(
"Failed to receive template from representation entity."
) from exc
try:
context = repre_entity["context"]
context = copy.deepcopy(repre_entity["context"])
_fix_representation_context_compatibility(context)
context["root"] = anatomy.roots
path = StringTemplate.format_strict_template(template, context)
except TemplateUnsolved as exc:
raise InvalidRepresentationContext((
"Couldn't resolve representation template with available data."
" Reason: {}".format(str(exc))
))
raise InvalidRepresentationContext(
"Failed to resolve representation template with available data."
) from exc
return path.normalized()
def get_representation_path(representation, root=None):
"""Get filename from representation document
There are three ways of getting the path from representation which are
tried in following sequence until successful.
1. Get template from representation['data']['template'] and data from
representation['context']. Then format template with the data.
2. Get template from project['config'] and format it with default data set
3. Get representation['data']['path'] and use it directly
Args:
representation(dict): representation document from the database
Returns:
str: fullpath of the representation
"""
if root is None:
from ayon_core.pipeline import get_current_project_name, Anatomy
anatomy = Anatomy(get_current_project_name())
return get_representation_path_with_anatomy(
representation, anatomy
)
def path_from_representation():
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = root
path = StringTemplate.format_strict_template(
template, context
)
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
if not path:
return path
normalized_path = os.path.normpath(path)
if os.path.exists(normalized_path):
return normalized_path
return path
def path_from_data():
if "path" not in representation["attrib"]:
return None
path = representation["attrib"]["path"]
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
if os.path.exists(path):
return os.path.normpath(path)
dir_path, file_name = os.path.split(path)
if not os.path.exists(dir_path):
return None
base_name, ext = os.path.splitext(file_name)
file_name_items = None
if "#" in base_name:
file_name_items = [part for part in base_name.split("#") if part]
elif "%" in base_name:
file_name_items = base_name.split("%")
if not file_name_items:
return None
filename_start = file_name_items[0]
for _file in os.listdir(dir_path):
if _file.startswith(filename_start) and _file.endswith(ext):
return os.path.normpath(path)
return (
path_from_representation() or path_from_data()
)
def get_representation_path_by_names(
project_name: str,
folder_path: str,
product_name: str,
version_name: str,
representation_name: str,
anatomy: Optional[Anatomy] = None) -> Optional[str]:
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
anatomy: Optional[Anatomy] = None
) -> Optional[TemplateResult]:
"""Get (latest) filepath for representation for folder and product.
See `get_representation_by_names` for more details.
@ -820,24 +932,23 @@ def get_representation_path_by_names(
representation_name
)
if not representation:
return
return None
if not anatomy:
anatomy = Anatomy(project_name)
if representation:
path = get_representation_path_with_anatomy(representation, anatomy)
return str(path).replace("\\", "/")
return get_representation_path(
project_name,
representation,
anatomy=anatomy,
)
def get_representation_by_names(
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
) -> Optional[dict]:
"""Get representation entity for asset and subset.
"""Get representation entity for folder and product.
If version_name is "hero" then return the hero version
If version_name is "latest" then return the latest version
@ -852,10 +963,10 @@ def get_representation_by_names(
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields=["id"])
if not folder_entity:
return
return None
if isinstance(product_name, dict) and "name" in product_name:
# Allow explicitly passing subset document
# Allow explicitly passing product entity document
product_entity = product_name
else:
product_entity = ayon_api.get_product_by_name(
@ -864,7 +975,7 @@ def get_representation_by_names(
folder_id=folder_entity["id"],
fields=["id"])
if not product_entity:
return
return None
if version_name == "hero":
version_entity = ayon_api.get_hero_version_by_product_id(
@ -876,7 +987,7 @@ def get_representation_by_names(
version_entity = ayon_api.get_version_by_name(
project_name, version_name, product_id=product_entity["id"])
if not version_entity:
return
return None
return ayon_api.get_representation_by_name(
project_name, representation_name, version_id=version_entity["id"])

View file

@ -1,6 +1,9 @@
from __future__ import annotations
import os
import inspect
import traceback
from typing import Optional
from ayon_core.lib import Logger
from ayon_core.lib.python_module_tools import (
@ -96,6 +99,77 @@ class DiscoverResult:
log.info(report)
def discover_plugins(
base_class: type,
paths: Optional[list[str]] = None,
classes: Optional[list[type]] = None,
ignored_classes: Optional[list[type]] = None,
allow_duplicates: bool = True,
):
"""Find and return subclasses of `superclass`
Args:
base_class (type): Class which determines discovered subclasses.
paths (Optional[list[str]]): List of paths to look for plug-ins.
classes (Optional[list[str]]): List of classes to filter.
ignored_classes (list[type]): List of classes that won't be added to
the output plugins.
allow_duplicates (bool): Validate class name duplications.
Returns:
DiscoverResult: Object holding successfully
discovered plugins, ignored plugins, plugins with missing
abstract implementation and duplicated plugin.
"""
ignored_classes = ignored_classes or []
paths = paths or []
classes = classes or []
result = DiscoverResult(base_class)
all_plugins = list(classes)
for path in paths:
modules, crashed = modules_from_path(path)
for (filepath, exc_info) in crashed:
result.crashed_file_paths[filepath] = exc_info
for item in modules:
filepath, module = item
result.add_module(module)
for cls in classes_from_module(base_class, module):
if cls is base_class:
continue
# Class has defined 'skip_discovery = True'
skip_discovery = cls.__dict__.get("skip_discovery")
if skip_discovery is True:
continue
all_plugins.append(cls)
if base_class not in ignored_classes:
ignored_classes.append(base_class)
plugin_names = set()
for cls in all_plugins:
if cls in ignored_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
if not allow_duplicates:
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
return result
class PluginDiscoverContext(object):
"""Store and discover registered types nad registered paths to types.
@ -141,58 +215,17 @@ class PluginDiscoverContext(object):
Union[DiscoverResult, list[Any]]: Object holding successfully
discovered plugins, ignored plugins, plugins with missing
abstract implementation and duplicated plugin.
"""
if not ignore_classes:
ignore_classes = []
result = DiscoverResult(superclass)
plugin_names = set()
registered_classes = self._registered_plugins.get(superclass) or []
registered_paths = self._registered_plugin_paths.get(superclass) or []
for cls in registered_classes:
if cls is superclass or cls in ignore_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
# Include plug-ins from registered paths
for path in registered_paths:
modules, crashed = modules_from_path(path)
for item in crashed:
filepath, exc_info = item
result.crashed_file_paths[filepath] = exc_info
for item in modules:
filepath, module = item
result.add_module(module)
for cls in classes_from_module(superclass, module):
if cls is superclass or cls in ignore_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
if not allow_duplicates:
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
result = discover_plugins(
superclass,
paths=registered_paths,
classes=registered_classes,
ignored_classes=ignore_classes,
allow_duplicates=allow_duplicates,
)
# Store in memory last result to keep in memory loaded modules
self._last_discovered_results[superclass] = result

View file

@ -29,6 +29,7 @@ from .lib import (
get_publish_template_name,
publish_plugins_discover,
filter_crashed_publish_paths,
load_help_content_from_plugin,
load_help_content_from_filepath,
@ -87,6 +88,7 @@ __all__ = (
"get_publish_template_name",
"publish_plugins_discover",
"filter_crashed_publish_paths",
"load_help_content_from_plugin",
"load_help_content_from_filepath",

View file

@ -1,6 +1,8 @@
"""Library functions for publishing."""
from __future__ import annotations
import os
import platform
import re
import sys
import inspect
import copy
@ -8,19 +10,19 @@ import warnings
import hashlib
import xml.etree.ElementTree
from typing import TYPE_CHECKING, Optional, Union, List, Any
import clique
import speedcopy
import logging
import pyblish.util
import pyblish.plugin
import pyblish.api
from ayon_api import (
get_server_api_connection,
get_representations,
get_last_version_by_product_name
)
import clique
import pyblish.util
import pyblish.plugin
import pyblish.api
import speedcopy
from ayon_core.lib import (
import_filepath,
Logger,
@ -122,7 +124,8 @@ def get_publish_template_name(
task_type,
project_settings=None,
hero=False,
logger=None
product_base_type: Optional[str] = None,
logger=None,
):
"""Get template name which should be used for passed context.
@ -140,17 +143,29 @@ def get_publish_template_name(
task_type (str): Task type on which is instance working.
project_settings (Dict[str, Any]): Prepared project settings.
hero (bool): Template is for hero version publishing.
product_base_type (Optional[str]): Product type for which should
be found template.
logger (logging.Logger): Custom logger used for 'filter_profiles'
function.
Returns:
str: Template name which should be used for integration.
"""
if not product_base_type:
msg = (
"Argument 'product_base_type' is not provided to"
" 'get_publish_template_name' function. This argument"
" will be required in future versions."
)
warnings.warn(msg, DeprecationWarning)
if logger:
logger.warning(msg)
template = None
filter_criteria = {
"hosts": host_name,
"product_types": product_type,
"product_base_types": product_base_type,
"task_names": task_name,
"task_types": task_type,
}
@ -179,7 +194,9 @@ class HelpContent:
self.detail = detail
def load_help_content_from_filepath(filepath):
def load_help_content_from_filepath(
filepath: str
) -> dict[str, dict[str, HelpContent]]:
"""Load help content from xml file.
Xml file may contain errors and warnings.
"""
@ -214,18 +231,84 @@ def load_help_content_from_filepath(filepath):
return output
def load_help_content_from_plugin(plugin):
def load_help_content_from_plugin(
plugin: pyblish.api.Plugin,
help_filename: Optional[str] = None,
) -> dict[str, dict[str, HelpContent]]:
cls = plugin
if not inspect.isclass(plugin):
cls = plugin.__class__
plugin_filepath = inspect.getfile(cls)
plugin_dir = os.path.dirname(plugin_filepath)
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", filename)
if help_filename is None:
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
help_filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", help_filename)
return load_help_content_from_filepath(filepath)
def filter_crashed_publish_paths(
project_name: str,
crashed_paths: set[str],
*,
project_settings: Optional[dict[str, Any]] = None,
) -> set[str]:
"""Filter crashed paths happened during plugins discovery.
Check if plugins discovery has enabled strict mode and filter crashed
paths that happened during discover based on regexes from settings.
Publishing should not start if any paths are returned.
Args:
project_name (str): Project name in which context plugins discovery
happened.
crashed_paths (set[str]): Crashed paths from plugins discovery report.
project_settings (Optional[dict[str, Any]]): Project settings.
Returns:
set[str]: Filtered crashed paths.
"""
filtered_paths = set()
# Nothing crashed all good...
if not crashed_paths:
return filtered_paths
if project_settings is None:
project_settings = get_project_settings(project_name)
discover_validation = (
project_settings["core"]["tools"]["publish"]["discover_validation"]
)
# Strict mode is not enabled.
if not discover_validation["enabled"]:
return filtered_paths
regexes = [
re.compile(value, re.IGNORECASE)
for value in discover_validation["ignore_paths"]
if value
]
is_windows = platform.system().lower() == "windows"
# Fitler path with regexes from settings
for path in crashed_paths:
# Normalize paths to use forward slashes on windows
if is_windows:
path = path.replace("\\", "/")
is_invalid = True
for regex in regexes:
if regex.match(path):
is_invalid = False
break
if is_invalid:
filtered_paths.add(path)
return filtered_paths
def publish_plugins_discover(
paths: Optional[list[str]] = None) -> DiscoverResult:
"""Find and return available pyblish plug-ins.
@ -812,7 +895,22 @@ def replace_with_published_scene_path(instance, replace_in_path=True):
template_data["comment"] = None
anatomy = instance.context.data["anatomy"]
template = anatomy.get_template_item("publish", "default", "path")
project_name = anatomy.project_name
task_name = task_type = None
task_entity = instance.data.get("taskEntity")
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
project_settings = instance.context.data["project_settings"]
template_name = get_publish_template_name(
project_name=project_name,
host_name=instance.context.data["hostName"],
product_type=workfile_instance.data["productType"],
task_name=task_name,
task_type=task_type,
project_settings=project_settings,
)
template = anatomy.get_template_item("publish", template_name, "path")
template_filled = template.format_strict(template_data)
file_path = os.path.normpath(template_filled)
@ -1064,14 +1162,16 @@ def main_cli_publish(
except ValueError:
pass
context = get_global_context()
project_settings = get_project_settings(context["project_name"])
install_ayon_plugins()
if addons_manager is None:
addons_manager = AddonsManager()
addons_manager = AddonsManager(project_settings)
applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is not None:
context = get_global_context()
env = applications_addon.get_farm_publish_environment_variables(
context["project_name"],
context["folder_path"],
@ -1094,17 +1194,33 @@ def main_cli_publish(
log.info("Running publish ...")
discover_result = publish_plugins_discover()
publish_plugins = discover_result.plugins
print(discover_result.get_report(only_errors=False))
filtered_crashed_paths = filter_crashed_publish_paths(
context["project_name"],
set(discover_result.crashed_file_paths),
project_settings=project_settings,
)
if filtered_crashed_paths:
joined_paths = "\n".join([
f"- {path}"
for path in filtered_crashed_paths
])
log.error(
"Plugin discovery strict mode is enabled."
" Crashed plugin paths that prevent from publishing:"
f"\n{joined_paths}"
)
sys.exit(1)
publish_plugins = discover_result.plugins
# Error exit as soon as any error occurs.
error_format = ("Failed {plugin.__name__}: "
"{error} -- {error.traceback}")
error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
for result in pyblish.util.publish_iter(plugins=publish_plugins):
if result["error"]:
log.error(error_format.format(**result))
# uninstall()
sys.exit(1)
log.info("Publish finished.")

View file

@ -1,7 +1,7 @@
import inspect
from abc import ABCMeta
import typing
from typing import Optional
from typing import Optional, Any
import pyblish.api
import pyblish.logic
@ -82,22 +82,51 @@ class PublishValidationError(PublishError):
class PublishXmlValidationError(PublishValidationError):
"""Raise an error from a dedicated xml file.
Can be useful to have one xml file with different possible messages that
helps to avoid flood code with dedicated artist messages.
XML files should live relative to the plugin file location:
'{plugin dir}/help/some_plugin.xml'.
Args:
plugin (pyblish.api.Plugin): Plugin that raised an error. Is used
to get path to xml file.
message (str): Exception message, can be technical, is used for
console output.
key (Optional[str]): XML file can contain multiple error messages, key
is used to get one of them. By default is used 'main'.
formatting_data (Optional[dict[str, Any]): Error message can have
variables to fill.
help_filename (Optional[str]): Name of xml file with messages. By
default, is used filename where plugin lives with .xml extension.
"""
def __init__(
self, plugin, message, key=None, formatting_data=None
):
self,
plugin: pyblish.api.Plugin,
message: str,
key: Optional[str] = None,
formatting_data: Optional[dict[str, Any]] = None,
help_filename: Optional[str] = None,
) -> None:
if key is None:
key = "main"
if not formatting_data:
formatting_data = {}
result = load_help_content_from_plugin(plugin)
result = load_help_content_from_plugin(plugin, help_filename)
content_obj = result["errors"][key]
description = content_obj.description.format(**formatting_data)
detail = content_obj.detail
if detail:
detail = detail.format(**formatting_data)
super(PublishXmlValidationError, self).__init__(
message, content_obj.title, description, detail
super().__init__(
message,
content_obj.title,
description,
detail
)

View file

@ -96,7 +96,6 @@ def get_folder_template_data(folder_entity, project_name):
Output dictionary contains keys:
- 'folder' - dictionary with 'name' key filled with folder name
- 'asset' - folder name
- 'hierarchy' - parent folder names joined with '/'
- 'parent' - direct parent name, project name used if is under
project
@ -132,7 +131,6 @@ def get_folder_template_data(folder_entity, project_name):
"path": path,
"parents": parents,
},
"asset": folder_name,
"hierarchy": hierarchy,
"parent": parent_name
}

View file

@ -299,7 +299,6 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None,
sdf format args metadata if enabled)
"""
# Add the order with the contribution path so that for future
# contributions we can again use it to magically fit into the
# ordering. We put this in the path because sublayer paths do
@ -317,20 +316,25 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None,
# If the layer was already in the layers, then replace it
for index, existing_path in enumerate(layer.subLayerPaths):
args = get_sdf_format_args(existing_path)
existing_layer = args.get("layer_id")
if existing_layer == layer_id:
existing_layer_id = args.get("layer_id")
if existing_layer_id == layer_id:
existing_layer = layer.subLayerPaths[index]
existing_order = args.get("order")
existing_order = int(existing_order) if existing_order else None
if order is not None and order != existing_order:
# We need to move the layer, so we will remove this index
# and then re-insert it below at the right order
log.debug(f"Removing existing layer: {existing_layer}")
del layer.subLayerPaths[index]
break
# Put it in the same position where it was before when swapping
# it with the original, also take over its order metadata
order = args.get("order")
if order is not None:
order = int(order)
else:
order = None
contribution_path = _format_path(contribution_path,
order=order,
order=existing_order,
layer_id=layer_id)
log.debug(
f"Replacing existing layer: {layer.subLayerPaths[index]} "
f"Replacing existing layer: {existing_layer} "
f"-> {contribution_path}"
)
layer.subLayerPaths[index] = contribution_path
@ -684,3 +688,20 @@ def get_sdf_format_args(path):
"""Return SDF_FORMAT_ARGS parsed to `dict`"""
_raw_path, data = Sdf.Layer.SplitIdentifier(path)
return data
def get_standard_default_prim_name(folder_path: str) -> str:
"""Return the AYON-specified default prim name for a folder path.
This is used e.g. for the default prim in AYON USD Contribution workflows.
"""
folder_name: str = folder_path.rsplit("/", 1)[-1]
# Prim names are not allowed to start with a digit in USD. Authoring them
# would mean generating essentially garbage data and may result in
# unexpected behavior in certain USD or DCC versions, like failure to
# refresh in usdview or crashes in Houdini 21.
if folder_name and folder_name[0].isdigit():
folder_name = f"_{folder_name}"
return folder_name

View file

@ -1,16 +1,19 @@
from __future__ import annotations
from typing import Optional, Any
from ayon_core.lib.profiles_filtering import filter_profiles
from ayon_core.settings import get_project_settings
def get_versioning_start(
project_name,
host_name,
task_name=None,
task_type=None,
product_type=None,
product_name=None,
project_settings=None,
):
project_name: str,
host_name: str,
task_name: Optional[str] = None,
task_type: Optional[str] = None,
product_type: Optional[str] = None,
product_name: Optional[str] = None,
project_settings: Optional[dict[str, Any]] = None,
) -> int:
"""Get anatomy versioning start"""
if not project_settings:
project_settings = get_project_settings(project_name)
@ -22,14 +25,12 @@ def get_versioning_start(
if not profiles:
return version_start
# TODO use 'product_types' and 'product_name' instead of
# 'families' and 'subsets'
filtering_criteria = {
"host_names": host_name,
"families": product_type,
"product_types": product_type,
"product_names": product_name,
"task_names": task_name,
"task_types": task_type,
"subsets": product_name
}
profile = filter_profiles(profiles, filtering_criteria)

View file

@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name()
return self._loaders_by_name
def get_linked_folder_entities(self, link_type: Optional[str]):
def get_linked_folder_entities(
self,
link_type: Optional[str],
folder_path_regex: Optional[str],
):
if not link_type:
return []
project_name = self.project_name
@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC):
if link["entityType"] == "folder"
}
return list(get_folders(project_name, folder_ids=linked_folder_ids))
return list(get_folders(
project_name,
folder_path_regex=folder_path_regex,
folder_ids=linked_folder_ids,
))
def _collect_creators(self):
self._creators_by_name = {
@ -832,14 +840,24 @@ class AbstractTemplateBuilder(ABC):
host_name = self.host_name
task_name = self.current_task_name
task_type = self.current_task_type
folder_path = self.current_folder_path
folder_type = None
folder_entity = self.current_folder_entity
if folder_entity:
folder_type = folder_entity["folderType"]
filter_data = {
"task_types": task_type,
"task_names": task_name,
"folder_types": folder_type,
"folder_paths": folder_path,
}
build_profiles = self._get_build_profiles()
profile = filter_profiles(
build_profiles,
{
"task_types": task_type,
"task_names": task_name
}
filter_data,
logger=self.log
)
if not profile:
raise TemplateProfileNotFound((
@ -1465,7 +1483,7 @@ class PlaceholderLoadMixin(object):
tooltip=(
"Link Type\n"
"\nDefines what type of link will be used to"
" link the asset to the current folder."
" link the product to the current folder."
)
),
attribute_definitions.EnumDef(
@ -1638,7 +1656,10 @@ class PlaceholderLoadMixin(object):
linked_folder_entity["id"]
for linked_folder_entity in (
self.builder.get_linked_folder_entities(
link_type=link_type))
link_type=link_type,
folder_path_regex=folder_path_regex
)
)
]
if not folder_ids:
@ -1666,6 +1687,8 @@ class PlaceholderLoadMixin(object):
for version in get_last_versions(
project_name, filtered_product_ids, fields={"id"}
).values()
# Version may be none if a product has no versions
if version is not None
)
return list(get_representations(
project_name,

View file

@ -1,34 +0,0 @@
from ayon_core.style import get_default_entity_icon_color
from ayon_core.pipeline import load
class CopyFile(load.LoaderPlugin):
"""Copy the published file to be pasted at the desired location"""
representations = {"*"}
product_types = {"*"}
label = "Copy File"
order = 10
icon = "copy"
color = get_default_entity_icon_color()
def load(self, context, name=None, namespace=None, data=None):
path = self.filepath_from_context(context)
self.log.info("Added copy to clipboard: {0}".format(path))
self.copy_file_to_clipboard(path)
@staticmethod
def copy_file_to_clipboard(path):
from qtpy import QtCore, QtWidgets
clipboard = QtWidgets.QApplication.clipboard()
assert clipboard, "Must have running QApplication instance"
# Build mime data for clipboard
data = QtCore.QMimeData()
url = QtCore.QUrl.fromLocalFile(path)
data.setUrls([url])
# Set to Clipboard
clipboard.setMimeData(data)

View file

@ -1,29 +0,0 @@
import os
from ayon_core.pipeline import load
class CopyFilePath(load.LoaderPlugin):
"""Copy published file path to clipboard"""
representations = {"*"}
product_types = {"*"}
label = "Copy File Path"
order = 20
icon = "clipboard"
color = "#999999"
def load(self, context, name=None, namespace=None, data=None):
path = self.filepath_from_context(context)
self.log.info("Added file path to clipboard: {0}".format(path))
self.copy_path_to_clipboard(path)
@staticmethod
def copy_path_to_clipboard(path):
from qtpy import QtWidgets
clipboard = QtWidgets.QApplication.clipboard()
assert clipboard, "Must have running QApplication instance"
# Set to Clipboard
clipboard.setText(os.path.normpath(path))

View file

@ -62,8 +62,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin):
ignored_representation_names: list[str] = []
db_representation_context_keys = [
"project", "folder", "asset", "hierarchy", "task", "product",
"subset", "family", "representation", "username", "user", "output"
"project", "folder", "hierarchy", "task", "product",
"representation", "username", "user", "output"
]
use_hardlinks = False
@ -75,6 +75,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin):
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
)
msgBox.exec_()

View file

@ -1,477 +0,0 @@
import collections
import os
import uuid
from typing import List, Dict, Any
import clique
import ayon_api
from ayon_api.operations import OperationsSession
import qargparse
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.lib import format_file_size
from ayon_core.pipeline import load, Anatomy
from ayon_core.pipeline.load import (
get_representation_path_with_anatomy,
InvalidRepresentationContext,
)
class DeleteOldVersions(load.ProductLoaderPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = ["*"]
product_types = {"*"}
tool_names = ["library_loader"]
label = "Delete Old Versions"
order = 35
icon = "trash"
color = "#d8d8d8"
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
requires_confirmation = True
def delete_whole_dir_paths(self, dir_paths, delete=True):
size = 0
for dir_path in dir_paths:
# Delete all files and folders in dir path
for root, dirs, files in os.walk(dir_path, topdown=False):
for name in files:
file_path = os.path.join(root, name)
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
for name in dirs:
if delete:
os.rmdir(os.path.join(root, name))
if not delete:
continue
# Delete even the folder and it's parents folders if they are empty
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
os.rmdir(os.path.join(dir_path))
return size
def path_from_representation(self, representation, anatomy):
try:
context = representation["context"]
except KeyError:
return (None, None)
try:
path = get_representation_path_with_anatomy(
representation, anatomy
)
except InvalidRepresentationContext:
return (None, None)
sequence_path = None
if "frame" in context:
context["frame"] = self.sequence_splitter
sequence_path = get_representation_path_with_anatomy(
representation, anatomy
)
if sequence_path:
sequence_path = sequence_path.normalized()
return (path.normalized(), sequence_path)
def delete_only_repre_files(self, dir_paths, file_paths, delete=True):
size = 0
for dir_id, dir_path in dir_paths.items():
dir_files = os.listdir(dir_path)
collections, remainders = clique.assemble(dir_files)
for file_path, seq_path in file_paths[dir_id]:
file_path_base = os.path.split(file_path)[1]
# Just remove file if `frame` key was not in context or
# filled path is in remainders (single file sequence)
if not seq_path or file_path_base in remainders:
if not os.path.exists(file_path):
self.log.debug(
"File was not found: {}".format(file_path)
)
continue
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
if file_path_base in remainders:
remainders.remove(file_path_base)
continue
seq_path_base = os.path.split(seq_path)[1]
head, tail = seq_path_base.split(self.sequence_splitter)
final_col = None
for collection in collections:
if head != collection.head or tail != collection.tail:
continue
final_col = collection
break
if final_col is not None:
# Fill full path to head
final_col.head = os.path.join(dir_path, final_col.head)
for _file_path in final_col:
if os.path.exists(_file_path):
size += os.path.getsize(_file_path)
if delete:
os.remove(_file_path)
self.log.debug(
"Removed file: {}".format(_file_path)
)
_seq_path = final_col.format("{head}{padding}{tail}")
self.log.debug("Removed files: {}".format(_seq_path))
collections.remove(final_col)
elif os.path.exists(file_path):
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
else:
self.log.debug(
"File was not found: {}".format(file_path)
)
# Delete as much as possible parent folders
if not delete:
return size
for dir_path in dir_paths.values():
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
self.log.debug("Removed folder: {}".format(dir_path))
os.rmdir(dir_path)
return size
def message(self, text):
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
)
msgBox.exec_()
def _confirm_delete(self,
contexts: List[Dict[str, Any]],
versions_to_keep: int) -> bool:
"""Prompt user for a deletion confirmation"""
contexts_list = "\n".join(sorted(
"- {folder[name]} > {product[name]}".format_map(context)
for context in contexts
))
num_contexts = len(contexts)
s = "s" if num_contexts > 1 else ""
text = (
"Are you sure you want to delete versions?\n\n"
f"This will keep only the last {versions_to_keep} "
f"versions for the {num_contexts} selected product{s}."
)
informative_text = "Warning: This will delete files from disk"
detailed_text = (
f"Keep only {versions_to_keep} versions for:\n{contexts_list}"
)
messagebox = QtWidgets.QMessageBox()
messagebox.setIcon(QtWidgets.QMessageBox.Warning)
messagebox.setWindowTitle("Delete Old Versions")
messagebox.setText(text)
messagebox.setInformativeText(informative_text)
messagebox.setDetailedText(detailed_text)
messagebox.setStandardButtons(
QtWidgets.QMessageBox.Yes
| QtWidgets.QMessageBox.Cancel
)
messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel)
messagebox.setStyleSheet(style.load_stylesheet())
messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
return messagebox.exec_() == QtWidgets.QMessageBox.Yes
def get_data(self, context, versions_count):
product_entity = context["product"]
folder_entity = context["folder"]
project_name = context["project"]["name"]
anatomy = Anatomy(project_name, project_entity=context["project"])
version_fields = ayon_api.get_default_fields_for_type("version")
version_fields.add("tags")
versions = list(ayon_api.get_versions(
project_name,
product_ids=[product_entity["id"]],
active=None,
hero=False,
fields=version_fields
))
self.log.debug(
"Version Number ({})".format(len(versions))
)
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
def sort_func(ent):
return int(ent["version"])
all_last_versions = []
for _parent_id, _versions in versions_by_parent.items():
for idx, version in enumerate(
sorted(_versions, key=sort_func, reverse=True)
):
if idx >= versions_count:
break
all_last_versions.append(version)
self.log.debug("Collected versions ({})".format(len(versions)))
# Filter latest versions
for version in all_last_versions:
versions.remove(version)
# Update versions_by_parent without filtered versions
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
# Filter already deleted versions
versions_to_pop = []
for version in versions:
if "deleted" in version["tags"]:
versions_to_pop.append(version)
for version in versions_to_pop:
msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format(
folder_entity["path"],
product_entity["name"],
version["version"]
)
self.log.debug((
"Skipping version. Already tagged as inactive. < {} >"
).format(msg))
versions.remove(version)
version_ids = [ent["id"] for ent in versions]
self.log.debug(
"Filtered versions to delete ({})".format(len(version_ids))
)
if not version_ids:
msg = "Skipping processing. Nothing to delete on {}/{}".format(
folder_entity["path"], product_entity["name"]
)
self.log.info(msg)
print(msg)
return
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))
self.log.debug(
"Collected representations to remove ({})".format(len(repres))
)
dir_paths = {}
file_paths_by_dir = collections.defaultdict(list)
for repre in repres:
file_path, seq_path = self.path_from_representation(
repre, anatomy
)
if file_path is None:
self.log.debug((
"Could not format path for represenation \"{}\""
).format(str(repre)))
continue
dir_path = os.path.dirname(file_path)
dir_id = None
for _dir_id, _dir_path in dir_paths.items():
if _dir_path == dir_path:
dir_id = _dir_id
break
if dir_id is None:
dir_id = uuid.uuid4()
dir_paths[dir_id] = dir_path
file_paths_by_dir[dir_id].append([file_path, seq_path])
dir_ids_to_pop = []
for dir_id, dir_path in dir_paths.items():
if os.path.exists(dir_path):
continue
dir_ids_to_pop.append(dir_id)
# Pop dirs from both dictionaries
for dir_id in dir_ids_to_pop:
dir_paths.pop(dir_id)
paths = file_paths_by_dir.pop(dir_id)
# TODO report of missing directories?
paths_msg = ", ".join([
"'{}'".format(path[0].replace("\\", "/")) for path in paths
])
self.log.debug((
"Folder does not exist. Deleting its files skipped: {}"
).format(paths_msg))
return {
"dir_paths": dir_paths,
"file_paths_by_dir": file_paths_by_dir,
"versions": versions,
"folder": folder_entity,
"product": product_entity,
"archive_product": versions_count == 0
}
def main(self, project_name, data, remove_publish_folder):
# Size of files.
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(data["dir_paths"].values())
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"]
)
op_session = OperationsSession()
for version in data["versions"]:
orig_version_tags = version["tags"]
version_tags = list(orig_version_tags)
changes = {}
if "deleted" not in version_tags:
version_tags.append("deleted")
changes["tags"] = version_tags
if version["active"]:
changes["active"] = False
if not changes:
continue
op_session.update_entity(
project_name, "version", version["id"], changes
)
op_session.commit()
return size
def load(self, contexts, name=None, namespace=None, options=None):
# Get user options
versions_to_keep = 2
remove_publish_folder = False
if options:
versions_to_keep = options.get(
"versions_to_keep", versions_to_keep
)
remove_publish_folder = options.get(
"remove_publish_folder", remove_publish_folder
)
# Because we do not want this run by accident we will add an extra
# user confirmation
if (
self.requires_confirmation
and not self._confirm_delete(contexts, versions_to_keep)
):
return
try:
size = 0
for count, context in enumerate(contexts):
data = self.get_data(context, versions_to_keep)
if not data:
continue
project_name = context["project"]["name"]
size += self.main(project_name, data, remove_publish_folder)
print("Progressing {}/{}".format(count + 1, len(contexts)))
msg = "Total size of files: {}".format(format_file_size(size))
self.log.info(msg)
self.message(msg)
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
class CalculateOldVersions(DeleteOldVersions):
"""Calculate file size of old versions"""
label = "Calculate Old Versions"
order = 30
tool_names = ["library_loader"]
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
requires_confirmation = False
def main(self, project_name, data, remove_publish_folder):
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(
data["dir_paths"].values(), delete=False
)
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"], delete=False
)
return size

View file

@ -1,36 +0,0 @@
import sys
import os
import subprocess
from ayon_core.pipeline import load
def open(filepath):
"""Open file with system default executable"""
if sys.platform.startswith('darwin'):
subprocess.call(('open', filepath))
elif os.name == 'nt':
os.startfile(filepath)
elif os.name == 'posix':
subprocess.call(('xdg-open', filepath))
class OpenFile(load.LoaderPlugin):
"""Open Image Sequence or Video with system default"""
product_types = {"render2d"}
representations = {"*"}
label = "Open"
order = -10
icon = "play-circle"
color = "orange"
def load(self, context, name, namespace, data):
path = self.filepath_from_context(context)
if not os.path.exists(path):
raise RuntimeError("File not found: {}".format(path))
self.log.info("Opening : {}".format(path))
open(path)

View file

@ -1,56 +0,0 @@
import os
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import get_ayon_launcher_args, run_detached_process
from ayon_core.pipeline import load
from ayon_core.pipeline.load import LoadError
class PushToProject(load.ProductLoaderPlugin):
"""Export selected versions to different project"""
is_multiple_contexts_compatible = True
representations = {"*"}
product_types = {"*"}
label = "Push to project"
order = 35
icon = "send"
color = "#d8d8d8"
def load(self, contexts, name=None, namespace=None, options=None):
filtered_contexts = [
context
for context in contexts
if context.get("project") and context.get("version")
]
if not filtered_contexts:
raise LoadError("Nothing to push for your selection")
folder_ids = set(
context["folder"]["id"]
for context in filtered_contexts
)
if len(folder_ids) > 1:
raise LoadError("Please select products from single folder")
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
"tools",
"push_to_project",
"main.py"
)
project_name = filtered_contexts[0]["project"]["name"]
version_ids = {
context["version"]["id"]
for context in filtered_contexts
}
args = get_ayon_launcher_args(
push_tool_script_path,
"--project", project_name,
"--versions", ",".join(version_ids)
)
run_detached_process(args)

View file

@ -0,0 +1,122 @@
import os
import collections
from typing import Optional, Any
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class CopyFileActionPlugin(LoaderActionPlugin):
"""Copy published file path to clipboard"""
identifier = "core.copy-action"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
output = []
if not repres:
return output
repre_ids_by_name = collections.defaultdict(set)
for repre in repres:
repre_ids_by_name[repre["name"]].add(repre["id"])
for repre_name, repre_ids in repre_ids_by_name.items():
repre_id = next(iter(repre_ids), None)
if not repre_id:
continue
output.append(
LoaderActionItem(
label=repre_name,
order=32,
group_label="Copy file path",
data={
"representation_id": repre_id,
"action": "copy-path",
},
icon={
"type": "material-symbols",
"name": "content_copy",
"color": "#999999",
}
)
)
output.append(
LoaderActionItem(
label=repre_name,
order=33,
group_label="Copy file",
data={
"representation_id": repre_id,
"action": "copy-file",
},
icon={
"type": "material-symbols",
"name": "file_copy",
"color": "#999999",
}
)
)
return output
def execute_action(
self,
selection: LoaderActionSelection,
data: dict,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
from qtpy import QtWidgets, QtCore
action = data["action"]
repre_id = data["representation_id"]
repre = next(iter(selection.entities.get_representations({repre_id})))
path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
self.log.info(f"Added file path to clipboard: {path}")
clipboard = QtWidgets.QApplication.clipboard()
if not clipboard:
return LoaderActionResult(
"Failed to copy file path to clipboard.",
success=False,
)
if action == "copy-path":
# Set to Clipboard
clipboard.setText(os.path.normpath(path))
return LoaderActionResult(
"Path stored to clipboard...",
success=True,
)
# Build mime data for clipboard
data = QtCore.QMimeData()
url = QtCore.QUrl.fromLocalFile(path)
data.setUrls([url])
# Set to Clipboard
clipboard.setMimeData(data)
return LoaderActionResult(
"File added to clipboard...",
success=True,
)

View file

@ -0,0 +1,388 @@
from __future__ import annotations
import os
import collections
import json
import shutil
from typing import Optional, Any
from ayon_api.operations import OperationsSession
from ayon_core.lib import (
format_file_size,
AbstractAttrDef,
NumberDef,
BoolDef,
TextDef,
UILabelDef,
)
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.actions import (
ActionForm,
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class DeleteOldVersions(LoaderActionPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
requires_confirmation = True
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
# Do not show in hosts
if self.host_name is not None:
return []
versions = selection.get_selected_version_entities()
if not versions:
return []
product_ids = {
version["productId"]
for version in versions
}
return [
LoaderActionItem(
label="Delete Versions",
order=35,
data={
"product_ids": list(product_ids),
"action": "delete-versions",
},
icon={
"type": "material-symbols",
"name": "delete",
"color": "#d8d8d8",
}
),
LoaderActionItem(
label="Calculate Versions size",
order=34,
data={
"product_ids": list(product_ids),
"action": "calculate-versions-size",
},
icon={
"type": "material-symbols",
"name": "auto_delete",
"color": "#d8d8d8",
}
)
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
step = form_values.get("step")
action = data["action"]
versions_to_keep = form_values.get("versions_to_keep")
remove_publish_folder = form_values.get("remove_publish_folder")
if step is None:
return self._first_step(
action,
versions_to_keep,
remove_publish_folder,
)
if versions_to_keep is None:
versions_to_keep = 2
if remove_publish_folder is None:
remove_publish_folder = False
product_ids = data["product_ids"]
if step == "prepare-data":
return self._prepare_data_step(
action,
versions_to_keep,
remove_publish_folder,
product_ids,
selection,
)
if step == "delete-versions":
return self._delete_versions_step(
selection.project_name, form_values
)
return None
def _first_step(
self,
action: str,
versions_to_keep: Optional[int],
remove_publish_folder: Optional[bool],
) -> LoaderActionResult:
fields: list[AbstractAttrDef] = [
TextDef(
"step",
visible=False,
),
NumberDef(
"versions_to_keep",
label="Versions to keep",
minimum=0,
default=2,
),
]
if action == "delete-versions":
fields.append(
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
)
)
form_values = {
key: value
for key, value in (
("remove_publish_folder", remove_publish_folder),
("versions_to_keep", versions_to_keep),
)
if value is not None
}
form_values["step"] = "prepare-data"
return LoaderActionResult(
form=ActionForm(
title="Delete Old Versions",
fields=fields,
),
form_values=form_values
)
def _prepare_data_step(
self,
action: str,
versions_to_keep: int,
remove_publish_folder: bool,
entity_ids: set[str],
selection: LoaderActionSelection,
):
versions_by_product_id = collections.defaultdict(list)
for version in selection.entities.get_products_versions(entity_ids):
# Keep hero version
if versions_to_keep != 0 and version["version"] < 0:
continue
versions_by_product_id[version["productId"]].append(version)
versions_to_delete = []
for product_id, versions in versions_by_product_id.items():
if versions_to_keep == 0:
versions_to_delete.extend(versions)
continue
if len(versions) <= versions_to_keep:
continue
versions.sort(key=lambda v: v["version"])
for _ in range(versions_to_keep):
if not versions:
break
versions.pop(-1)
versions_to_delete.extend(versions)
self.log.debug(
f"Collected versions to delete ({len(versions_to_delete)})"
)
version_ids = {
version["id"]
for version in versions_to_delete
}
if not version_ids:
return LoaderActionResult(
message="Skipping. Nothing to delete.",
success=False,
)
project = selection.entities.get_project()
anatomy = Anatomy(project["name"], project_entity=project)
repres = selection.entities.get_versions_representations(version_ids)
self.log.debug(
f"Collected representations to remove ({len(repres)})"
)
filepaths_by_repre_id = {}
repre_ids_by_version_id = {
version_id: []
for version_id in version_ids
}
for repre in repres:
repre_ids_by_version_id[repre["versionId"]].append(repre["id"])
filepaths_by_repre_id[repre["id"]] = [
anatomy.fill_root(repre_file["path"])
for repre_file in repre["files"]
]
size = 0
for filepaths in filepaths_by_repre_id.values():
for filepath in filepaths:
if os.path.exists(filepath):
size += os.path.getsize(filepath)
if action == "calculate-versions-size":
return LoaderActionResult(
message="Calculated size",
success=True,
form=ActionForm(
title="Calculated versions size",
fields=[
UILabelDef(
f"Total size of files: {format_file_size(size)}"
),
],
submit_label=None,
cancel_label="Close",
),
)
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
list(version_ids),
repre_ids_by_version_id,
filepaths_by_repre_id,
)
return LoaderActionResult(
form=form,
form_values=form_values
)
def _delete_versions_step(
self, project_name: str, form_values: dict[str, Any]
) -> LoaderActionResult:
delete_data = json.loads(form_values["delete_data"])
remove_publish_folder = form_values["remove_publish_folder"]
if form_values["delete_value"].lower() != "delete":
size = delete_data["size"]
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
delete_data["version_ids"],
delete_data["repre_ids_by_version_id"],
delete_data["filepaths_by_repre_id"],
True,
)
return LoaderActionResult(
form=form,
form_values=form_values,
)
version_ids = delete_data["version_ids"]
repre_ids_by_version_id = delete_data["repre_ids_by_version_id"]
filepaths_by_repre_id = delete_data["filepaths_by_repre_id"]
op_session = OperationsSession()
total_versions = len(version_ids)
try:
for version_idx, version_id in enumerate(version_ids):
self.log.info(
f"Progressing version {version_idx + 1}/{total_versions}"
)
for repre_id in repre_ids_by_version_id[version_id]:
for filepath in filepaths_by_repre_id[repre_id]:
publish_folder = os.path.dirname(filepath)
if remove_publish_folder:
if os.path.exists(publish_folder):
shutil.rmtree(
publish_folder, ignore_errors=True
)
continue
if os.path.exists(filepath):
os.remove(filepath)
op_session.delete_entity(
project_name, "representation", repre_id
)
op_session.delete_entity(
project_name, "version", version_id
)
self.log.info("All done")
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
return LoaderActionResult(
message="Failed to delete versions.",
success=False,
)
finally:
op_session.commit()
return LoaderActionResult(
message="Deleted versions",
success=True,
)
def _get_delete_form(
self,
size: int,
remove_publish_folder: bool,
version_ids: list[str],
repre_ids_by_version_id: dict[str, list[str]],
filepaths_by_repre_id: dict[str, list[str]],
repeated: bool = False,
) -> tuple[ActionForm, dict[str, Any]]:
versions_len = len(repre_ids_by_version_id)
fields = [
UILabelDef(
f"Going to delete {versions_len} versions<br/>"
f"- total size of files: {format_file_size(size)}<br/>"
),
UILabelDef("Are you sure you want to continue?"),
TextDef(
"delete_value",
placeholder="Type 'delete' to confirm...",
),
]
if repeated:
fields.append(UILabelDef(
"*Please fill in '**delete**' to confirm deletion.*"
))
fields.extend([
TextDef(
"delete_data",
visible=False,
),
TextDef(
"step",
visible=False,
),
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
visible=False,
)
])
form = ActionForm(
title="Delete versions",
submit_label="Delete",
cancel_label="Close",
fields=fields,
)
form_values = {
"delete_data": json.dumps({
"size": size,
"version_ids": version_ids,
"repre_ids_by_version_id": repre_ids_by_version_id,
"filepaths_by_repre_id": filepaths_by_repre_id,
}),
"step": "delete-versions",
"remove_publish_folder": remove_publish_folder,
}
return form, form_values

View file

@ -1,5 +1,6 @@
import platform
from collections import defaultdict
from typing import Optional, Any
import ayon_api
from qtpy import QtWidgets, QtCore, QtGui
@ -10,7 +11,12 @@ from ayon_core.lib import (
collect_frames,
get_datetime_data,
)
from ayon_core.pipeline import load, Anatomy
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.delivery import (
get_format_dict,
@ -20,43 +26,72 @@ from ayon_core.pipeline.delivery import (
)
class Delivery(load.ProductLoaderPlugin):
"""Export selected versions to folder structure from Template"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = {"*"}
product_types = {"*"}
tool_names = ["library_loader"]
class DeliveryAction(LoaderSimpleActionPlugin):
identifier = "core.delivery"
label = "Deliver Versions"
order = 35
icon = "upload"
color = "#d8d8d8"
icon = {
"type": "material-symbols",
"name": "upload",
"color": "#d8d8d8",
}
def message(self, text):
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
def is_compatible(self, selection: LoaderActionSelection) -> bool:
if self.host_name is not None:
return False
if not selection.selected_ids:
return False
return (
selection.versions_selected()
or selection.representations_selected()
)
msgBox.exec_()
def load(self, contexts, name=None, namespace=None, options=None):
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
version_ids = set()
if selection.selected_type == "representation":
versions = selection.entities.get_representations_versions(
selection.selected_ids
)
version_ids = {version["id"] for version in versions}
if selection.selected_type == "version":
version_ids = set(selection.selected_ids)
if not version_ids:
return LoaderActionResult(
message="No versions found in your selection",
success=False,
)
try:
dialog = DeliveryOptionsDialog(contexts, self.log)
# TODO run the tool in subprocess
dialog = DeliveryOptionsDialog(
selection.project_name, version_ids, self.log
)
dialog.exec_()
except Exception:
self.log.error("Failed to deliver versions.", exc_info=True)
return LoaderActionResult()
class DeliveryOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
def __init__(self, contexts, log=None, parent=None):
super(DeliveryOptionsDialog, self).__init__(parent=parent)
def __init__(
self,
project_name,
version_ids,
log=None,
parent=None,
):
super().__init__(parent=parent)
self.setWindowTitle("AYON - Deliver versions")
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
@ -70,13 +105,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
self.setStyleSheet(style.load_stylesheet())
project_name = contexts[0]["project"]["name"]
self.anatomy = Anatomy(project_name)
self._representations = None
self.log = log
self.currently_uploaded = 0
self._set_representations(project_name, contexts)
self._set_representations(project_name, version_ids)
dropdown = QtWidgets.QComboBox()
self.templates = self._get_templates(self.anatomy)
@ -316,9 +350,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
return templates
def _set_representations(self, project_name, contexts):
version_ids = {context["version"]["id"] for context in contexts}
def _set_representations(self, project_name, version_ids):
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))

View file

@ -2,11 +2,10 @@ import logging
import os
from pathlib import Path
from collections import defaultdict
from typing import Any, Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_api import get_representations
from ayon_core.pipeline import load, Anatomy
from ayon_core import resources, style
from ayon_core.lib.transcoding import (
IMAGE_EXTENSIONS,
@ -16,9 +15,16 @@ from ayon_core.lib import (
get_ffprobe_data,
is_oiio_supported,
)
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.tools.utils import show_message_dialog
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
OTIO = None
FRAME_SPLITTER = "__frame_splitter__"
@ -30,34 +36,99 @@ def _import_otio():
OTIO = opentimelineio
class ExportOTIO(load.ProductLoaderPlugin):
"""Export selected versions to OpenTimelineIO."""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = {"*"}
product_types = {"*"}
tool_names = ["library_loader"]
class ExportOTIO(LoaderSimpleActionPlugin):
identifier = "core.export-otio"
label = "Export OTIO"
group_label = None
order = 35
icon = "save"
color = "#d8d8d8"
icon = {
"type": "material-symbols",
"name": "save",
"color": "#d8d8d8",
}
def load(self, contexts, name=None, namespace=None, options=None):
def is_compatible(
self, selection: LoaderActionSelection
) -> bool:
# Don't show in hosts
if self.host_name is not None:
return False
return selection.versions_selected()
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
_import_otio()
version_ids = set(selection.selected_ids)
versions_by_id = {
version["id"]: version
for version in selection.entities.get_versions(version_ids)
}
product_ids = {
version["productId"]
for version in versions_by_id.values()
}
products_by_id = {
product["id"]: product
for product in selection.entities.get_products(product_ids)
}
folder_ids = {
product["folderId"]
for product in products_by_id.values()
}
folder_by_id = {
folder["id"]: folder
for folder in selection.entities.get_folders(folder_ids)
}
repre_entities = selection.entities.get_versions_representations(
version_ids
)
version_path_by_id = {}
for version in versions_by_id.values():
version_id = version["id"]
product_id = version["productId"]
product = products_by_id[product_id]
folder_id = product["folderId"]
folder = folder_by_id[folder_id]
version_path_by_id[version_id] = "/".join([
folder["path"],
product["name"],
version["name"]
])
try:
dialog = ExportOTIOOptionsDialog(contexts, self.log)
# TODO this should probably trigger a subprocess?
dialog = ExportOTIOOptionsDialog(
selection.project_name,
versions_by_id,
repre_entities,
version_path_by_id,
self.log
)
dialog.exec_()
except Exception:
self.log.error("Failed to export OTIO.", exc_info=True)
return LoaderActionResult()
class ExportOTIOOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
def __init__(self, contexts, log=None, parent=None):
def __init__(
self,
project_name,
versions_by_id,
repre_entities,
version_path_by_id,
log=None,
parent=None
):
# Not all hosts have OpenTimelineIO available.
self.log = log
@ -73,30 +144,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog):
| QtCore.Qt.WindowMinimizeButtonHint
)
project_name = contexts[0]["project"]["name"]
versions_by_id = {
context["version"]["id"]: context["version"]
for context in contexts
}
repre_entities = list(get_representations(
project_name, version_ids=set(versions_by_id)
))
version_by_representation_id = {
repre_entity["id"]: versions_by_id[repre_entity["versionId"]]
for repre_entity in repre_entities
}
version_path_by_id = {}
representations_by_version_id = {}
for context in contexts:
version_id = context["version"]["id"]
if version_id in version_path_by_id:
continue
representations_by_version_id[version_id] = []
version_path_by_id[version_id] = "/".join([
context["folder"]["path"],
context["product"]["name"],
context["version"]["name"]
])
representations_by_version_id = {
version_id: []
for version_id in versions_by_id
}
for repre_entity in repre_entities:
representations_by_version_id[repre_entity["versionId"]].append(

View file

@ -0,0 +1,360 @@
import os
import sys
import subprocess
import platform
import collections
import ctypes
from typing import Optional, Any, Callable
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
WINDOWS_USER_REG_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts"
r"\{ext}\UserChoice"
)
class _Cache:
"""Cache extensions information.
Notes:
The cache is cleared when loader tool is refreshed so it might be
moved to other place which is not cleared on refresh.
"""
supported_exts: set[str] = set()
unsupported_exts: set[str] = set()
@classmethod
def is_supported(cls, ext: str) -> bool:
return ext in cls.supported_exts
@classmethod
def already_checked(cls, ext: str) -> bool:
return (
ext in cls.supported_exts
or ext in cls.unsupported_exts
)
@classmethod
def set_ext_support(cls, ext: str, supported: bool) -> None:
if supported:
cls.supported_exts.add(ext)
else:
cls.unsupported_exts.add(ext)
def _extension_has_assigned_app_windows(ext: str) -> bool:
import winreg
progid = None
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
WINDOWS_USER_REG_PATH.format(ext=ext),
) as k:
progid, _ = winreg.QueryValueEx(k, "ProgId")
except OSError:
pass
if progid:
return True
try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k:
progid = winreg.QueryValueEx(k, None)[0]
except OSError:
pass
return bool(progid)
def _linux_find_desktop_file(desktop: str) -> Optional[str]:
for dirpath in (
os.path.expanduser("~/.local/share/applications"),
"/usr/share/applications",
"/usr/local/share/applications",
):
path = os.path.join(dirpath, desktop)
if os.path.isfile(path):
return path
return None
def _extension_has_assigned_app_linux(ext: str) -> bool:
import mimetypes
mime, _ = mimetypes.guess_type(f"file{ext}")
if not mime:
return False
try:
# xdg-mime query default <mime>
desktop = subprocess.check_output(
["xdg-mime", "query", "default", mime],
text=True
).strip() or None
except Exception:
desktop = None
if not desktop:
return False
desktop_path = _linux_find_desktop_file(desktop)
if not desktop_path:
return False
if desktop_path and os.path.isfile(desktop_path):
return True
return False
def _extension_has_assigned_app_macos(ext: str) -> bool:
# Uses CoreServices/LaunchServices and Uniform Type Identifiers via
# ctypes.
# Steps: ext -> UTI -> default handler bundle id for role 'all'.
cf = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
)
ls = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/Frameworks"
"/LaunchServices.framework/LaunchServices"
)
# CFType/CFString helpers
CFStringRef = ctypes.c_void_p
CFAllocatorRef = ctypes.c_void_p
CFIndex = ctypes.c_long
kCFStringEncodingUTF8 = 0x08000100
cf.CFStringCreateWithCString.argtypes = [
CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32
]
cf.CFStringCreateWithCString.restype = CFStringRef
cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32]
cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
cf.CFStringGetCString.argtypes = [
CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32
]
cf.CFStringGetCString.restype = ctypes.c_bool
cf.CFRelease.argtypes = [ctypes.c_void_p]
cf.CFRelease.restype = None
try:
UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/CoreServices"
).UTTypeCreatePreferredIdentifierForTag
except OSError:
# Fallback path (older systems)
UTTypeCreatePreferredIdentifierForTag = (
ls.UTTypeCreatePreferredIdentifierForTag
)
UTTypeCreatePreferredIdentifierForTag.argtypes = [
CFStringRef, CFStringRef, CFStringRef
]
UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef
LSRolesMask = ctypes.c_uint
kLSRolesAll = 0xFFFFFFFF
ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [
CFStringRef, LSRolesMask
]
ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef
def cfstr(py_s: str) -> CFStringRef:
return cf.CFStringCreateWithCString(
None, py_s.encode("utf-8"), kCFStringEncodingUTF8
)
def to_pystr(cf_s: CFStringRef) -> Optional[str]:
if not cf_s:
return None
# Try fast pointer
ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8)
if ptr:
return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
# Fallback buffer
buf_size = 1024
buf = ctypes.create_string_buffer(buf_size)
ok = cf.CFStringGetCString(
cf_s, buf, buf_size, kCFStringEncodingUTF8
)
if ok:
return buf.value.decode("utf-8")
return None
# Convert extension (without dot) to UTI
tag_class = cfstr("public.filename-extension")
tag_value = cfstr(ext.lstrip("."))
uti_ref = UTTypeCreatePreferredIdentifierForTag(
tag_class, tag_value, None
)
# Clean up temporary CFStrings
for ref in (tag_class, tag_value):
if ref:
cf.CFRelease(ref)
bundle_id = None
if uti_ref:
# Get default handler for the UTI
default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType(
uti_ref, kLSRolesAll
)
bundle_id = to_pystr(default_bundle_ref)
if default_bundle_ref:
cf.CFRelease(default_bundle_ref)
cf.CFRelease(uti_ref)
return bundle_id is not None
def _filter_supported_exts(
extensions: set[str], test_func: Callable
) -> set[str]:
filtered_exs: set[str] = set()
for ext in extensions:
if not _Cache.already_checked(ext):
_Cache.set_ext_support(ext, test_func(ext))
if _Cache.is_supported(ext):
filtered_exs.add(ext)
return filtered_exs
def filter_supported_exts(extensions: set[str]) -> set[str]:
if not extensions:
return set()
platform_name = platform.system().lower()
if platform_name == "windows":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_windows
)
if platform_name == "linux":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_linux
)
if platform_name == "darwin":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_macos
)
return set()
def open_file(filepath: str) -> None:
"""Open file with system default executable"""
if sys.platform.startswith("darwin"):
subprocess.call(("open", filepath))
elif os.name == "nt":
os.startfile(filepath)
elif os.name == "posix":
subprocess.call(("xdg-open", filepath))
class OpenFileAction(LoaderActionPlugin):
"""Open Image Sequence or Video with system default"""
identifier = "core.open-file"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
if not repres:
return []
repres_by_ext = collections.defaultdict(list)
for repre in repres:
repre_context = repre.get("context")
if not repre_context:
continue
ext = repre_context.get("ext")
if not ext:
path = repre["attrib"].get("path")
if path:
ext = os.path.splitext(path)[1]
if ext:
ext = ext.lower()
if not ext.startswith("."):
ext = f".{ext}"
repres_by_ext[ext.lower()].append(repre)
if not repres_by_ext:
return []
filtered_exts = filter_supported_exts(set(repres_by_ext))
repre_ids_by_name = collections.defaultdict(set)
for ext in filtered_exts:
for repre in repres_by_ext[ext]:
repre_ids_by_name[repre["name"]].add(repre["id"])
return [
LoaderActionItem(
label=repre_name,
group_label="Open file",
order=30,
data={"representation_ids": list(repre_ids)},
icon={
"type": "material-symbols",
"name": "file_open",
"color": "#ffffff",
}
)
for repre_name, repre_ids in repre_ids_by_name.items()
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
path = None
repre_path = None
repre_ids = data["representation_ids"]
for repre in selection.entities.get_representations(repre_ids):
repre_path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
if os.path.exists(repre_path):
path = repre_path
break
if path is None:
if repre_path is None:
return LoaderActionResult(
"Failed to fill representation path...",
success=False,
)
return LoaderActionResult(
"File to open was not found...",
success=False,
)
self.log.info(f"Opening: {path}")
open_file(path)
return LoaderActionResult(
"File was opened...",
success=True,
)

View file

@ -0,0 +1,69 @@
import os
from typing import Optional, Any
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import get_ayon_launcher_args, run_detached_process
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
class PushToProject(LoaderSimpleActionPlugin):
identifier = "core.push-to-project"
label = "Push to project"
order = 35
icon = {
"type": "material-symbols",
"name": "send",
"color": "#d8d8d8",
}
def is_compatible(
self, selection: LoaderActionSelection
) -> bool:
if not selection.versions_selected():
return False
version_ids = set(selection.selected_ids)
product_ids = {
product["id"]
for product in selection.entities.get_versions_products(
version_ids
)
}
folder_ids = {
folder["id"]
for folder in selection.entities.get_products_folders(
product_ids
)
}
if len(folder_ids) == 1:
return True
return False
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
"tools",
"push_to_project",
"main.py"
)
args = get_ayon_launcher_args(
push_tool_script_path,
"--project", selection.project_name,
"--versions", ",".join(selection.selected_ids)
)
run_detached_process(args)
return LoaderActionResult(
message="Push to project tool opened...",
success=True,
)

View file

@ -301,8 +301,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
product_name = instance.data["productName"]
product_type = instance.data["productType"]
anatomy_data.update({
"family": product_type,
"subset": product_name,
"product": {
"name": product_name,
"type": product_type,

View file

@ -52,7 +52,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
context, self.__class__
):
# Skip instances that already have audio filled
if instance.data.get("audio"):
if "audio" in instance.data:
self.log.debug(
"Skipping Audio collection. It is already collected"
)

View file

@ -25,7 +25,7 @@ class CollectManagedStagingDir(pyblish.api.InstancePlugin):
Location of the folder is configured in:
`ayon+anatomy://_/templates/staging`.
Which family/task type/subset is applicable is configured in:
Which product type/task type/product is applicable is configured in:
`ayon+settings://core/tools/publish/custom_staging_dir_profiles`
"""

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Any
import ayon_api
import ayon_api.utils
@ -11,20 +13,6 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder + 0.0001
label = "Collect Versions Loaded in Scene"
hosts = [
"aftereffects",
"blender",
"celaction",
"fusion",
"harmony",
"hiero",
"houdini",
"maya",
"nuke",
"photoshop",
"resolve",
"tvpaint"
]
def process(self, context):
host = registered_host()
@ -46,6 +34,8 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
self.log.debug("No loaded containers found in scene.")
return
containers = self._filter_invalid_containers(containers)
repre_ids = {
container["representation"]
for container in containers
@ -92,3 +82,28 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
self.log.debug(f"Collected {len(loaded_versions)} loaded versions.")
context.data["loadedVersions"] = loaded_versions
def _filter_invalid_containers(
self,
containers: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Filter out invalid containers lacking required keys.
Skip any invalid containers that lack 'representation' or 'name'
keys to avoid KeyError.
"""
# Only filter by what's required for this plug-in instead of validating
# a full container schema.
required_keys = {"name", "representation"}
valid = []
for container in containers:
missing = [key for key in required_keys if key not in container]
if missing:
self.log.warning(
"Skipping invalid container, missing required keys:"
" {}. {}".format(", ".join(missing), container)
)
continue
valid.append(container)
return valid

View file

@ -316,22 +316,8 @@ class ExtractBurnin(publish.Extractor):
burnin_values = {}
for key in self.positions:
value = burnin_def.get(key)
if not value:
continue
# TODO remove replacements
burnin_values[key] = (
value
.replace("{task}", "{task[name]}")
.replace("{product[name]}", "{subset}")
.replace("{Product[name]}", "{Subset}")
.replace("{PRODUCT[NAME]}", "{SUBSET}")
.replace("{product[type]}", "{family}")
.replace("{Product[type]}", "{Family}")
.replace("{PRODUCT[TYPE]}", "{FAMILY}")
.replace("{folder[name]}", "{asset}")
.replace("{Folder[name]}", "{Asset}")
.replace("{FOLDER[NAME]}", "{ASSET}")
)
if value:
burnin_values[key] = value
# Remove "delete" tag from new representation
if "delete" in new_repre["tags"]:

View file

@ -11,6 +11,7 @@ from ayon_core.lib import (
is_oiio_supported,
)
from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert,
)
@ -86,15 +87,19 @@ class ExtractOIIOTranscode(publish.Extractor):
profile_output_defs = profile["outputs"]
new_representations = []
repres = instance.data["representations"]
for idx, repre in enumerate(list(repres)):
# target space, display and view might be defined upstream
# TODO: address https://github.com/ynput/ayon-core/pull/1268#discussion_r2156555474
# Implement upstream logic to handle target_colorspace,
# target_display, target_view in other DCCs
target_colorspace = False
target_display = instance.data.get("colorspaceDisplay")
target_view = instance.data.get("colorspaceView")
scene_display = instance.data.get(
"sceneDisplay",
# Backward compatibility
instance.data.get("colorspaceDisplay")
)
scene_view = instance.data.get(
"sceneView",
# Backward compatibility
instance.data.get("colorspaceView")
)
for idx, repre in enumerate(list(repres)):
self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
if not self._repre_is_valid(repre):
continue
@ -111,7 +116,17 @@ class ExtractOIIOTranscode(publish.Extractor):
self.log.warning("Config file doesn't exist, skipping")
continue
# Get representation files to convert
if isinstance(repre["files"], list):
repre_files_to_convert = copy.deepcopy(repre["files"])
else:
repre_files_to_convert = [repre["files"]]
# Process each output definition
for output_def in profile_output_defs:
# Local copy to avoid accidental mutable changes
files_to_convert = list(repre_files_to_convert)
output_name = output_def["name"]
new_repre = copy.deepcopy(repre)
@ -122,11 +137,6 @@ class ExtractOIIOTranscode(publish.Extractor):
)
new_repre["stagingDir"] = new_staging_dir
if isinstance(new_repre["files"], list):
files_to_convert = copy.deepcopy(new_repre["files"])
else:
files_to_convert = [new_repre["files"]]
output_extension = output_def["extension"]
output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre,
@ -136,24 +146,18 @@ class ExtractOIIOTranscode(publish.Extractor):
transcoding_type = output_def["transcoding_type"]
# NOTE: we use colorspace_data as the fallback values for
# the target colorspace.
# Set target colorspace/display/view based on transcoding type
target_colorspace = None
target_view = None
target_display = None
if transcoding_type == "colorspace":
# TODO: Should we fallback to the colorspace
# (which used as source above) ?
# or should we compute the target colorspace from
# current view and display ?
target_colorspace = (output_def["colorspace"] or
colorspace_data.get("colorspace"))
target_colorspace = output_def["colorspace"]
elif transcoding_type == "display_view":
display_view = output_def["display_view"]
target_view = (
display_view["view"]
or colorspace_data.get("view"))
target_display = (
display_view["display"]
or colorspace_data.get("display")
)
# If empty values are provided in output definition,
# fallback to scene display/view that is collected from DCC
target_view = display_view["view"] or scene_view
target_display = display_view["display"] or scene_display
# both could be already collected by DCC,
# but could be overwritten when transcoding
@ -168,30 +172,65 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"])
files_to_convert = self._translate_to_sequence(
sequence_files = self._translate_to_sequence(
files_to_convert)
self.log.debug("Files to convert: {}".format(files_to_convert))
for file_name in files_to_convert:
self.log.debug("Files to convert: {}".format(sequence_files))
missing_rgba_review_channels = False
for file_name in sequence_files:
if isinstance(file_name, clique.Collection):
# Support sequences with holes by supplying
# dedicated `--frames` argument to `oiiotool`
# Create `frames` string like "1001-1002,1004,1010-1012
# Create `filename` string like "file.#.exr"
frames = file_name.format("{ranges}").replace(" ", "")
frame_padding = file_name.padding
file_name = file_name.format("{head}#{tail}")
parallel_frames = True
elif isinstance(file_name, str):
# Single file
frames = None
frame_padding = None
parallel_frames = False
else:
raise TypeError(
f"Unsupported file name type: {type(file_name)}."
" Expected str or clique.Collection."
)
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
input_path = os.path.join(original_staging_dir, file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
try:
oiio_color_convert(
input_path=input_path,
output_path=output_path,
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
frames=frames,
frame_padding=frame_padding,
parallel_frames=parallel_frames,
logger=self.log
)
except MissingRGBAChannelsError as exc:
missing_rgba_review_channels = True
self.log.error(exc)
self.log.error(
"Skipping OIIO Transcode. Unknown RGBA channels"
f" for colorspace conversion in file: {input_path}"
)
break
oiio_color_convert(
input_path=input_path,
output_path=output_path,
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
if missing_rgba_review_channels:
# Stop processing this representation
break
# cleanup temporary transcoded files
for file_name in new_repre["files"]:
@ -217,11 +256,11 @@ class ExtractOIIOTranscode(publish.Extractor):
added_review = True
# If there is only 1 file outputted then convert list to
# string, cause that'll indicate that its not a sequence.
# string, because that'll indicate that it is not a sequence.
if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0]
# If the source representation has "review" tag, but its not
# If the source representation has "review" tag, but it's not
# part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and
# their outputs will clash in integration.
@ -271,42 +310,29 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert):
"""Returns original list or list with filename formatted in single
sequence format.
"""Returns original individual filepaths or list of clique.Collection.
Uses clique to find frame sequence, in this case it merges all frames
into sequence format (FRAMESTART-FRAMEEND#) and returns it.
If sequence not found, it returns original list
Uses clique to find frame sequence, and return the collections instead.
If sequence not detected in input filenames, it returns original list.
Args:
files_to_convert (list): list of file names
files_to_convert (list[str]): list of file names
Returns:
(list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr]
list[str | clique.Collection]: List of
filepaths ['fileA.exr', 'fileB.exr']
or clique.Collection for a sequence.
"""
pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble(
files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True)
if collections:
if len(collections) > 1:
raise ValueError(
"Too many collections {}".format(collections))
collection = collections[0]
frames = list(collection.indexes)
if collection.holes().indexes:
return files_to_convert
# Get the padding from the collection
# This is the number of digits used in the frame numbers
padding = collection.padding
frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding)
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)
files_to_convert = [file_name]
return collections
return files_to_convert

View file

@ -0,0 +1,353 @@
from __future__ import annotations
from typing import Any, Optional
import os
import copy
import clique
import pyblish.api
from ayon_core.pipeline import (
publish,
get_temp_dir
)
from ayon_core.lib import (
is_oiio_supported,
get_oiio_tool_args,
run_subprocess
)
from ayon_core.lib.transcoding import IMAGE_EXTENSIONS
from ayon_core.lib.profiles_filtering import filter_profiles
class ExtractOIIOPostProcess(publish.Extractor):
"""Process representations through `oiiotool` with profile defined
settings so that e.g. color space conversions can be applied or images
could be converted to scanline, resized, etc. regardless of colorspace
data.
"""
label = "OIIO Post Process"
order = pyblish.api.ExtractorOrder + 0.020
settings_category = "core"
optional = True
# Supported extensions
supported_exts = {ext.lstrip(".") for ext in IMAGE_EXTENSIONS}
# Configurable by Settings
profiles = None
options = None
def process(self, instance):
if instance.data.get("farm"):
self.log.debug("Should be processed on farm, skipping.")
return
if not self.profiles:
self.log.debug("No profiles present for OIIO Post Process")
return
if not instance.data.get("representations"):
self.log.debug("No representations, skipping.")
return
if not is_oiio_supported():
self.log.warning("OIIO not supported, no transcoding possible.")
return
new_representations = []
for idx, repre in enumerate(list(instance.data["representations"])):
self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
if not self._repre_is_valid(repre):
continue
# We check profile per representation name and extension because
# it's included in the profile check. As such, an instance may have
# a different profile applied per representation.
profile = self._get_profile(
instance,
repre
)
if not profile:
continue
# Get representation files to convert
if isinstance(repre["files"], list):
repre_files_to_convert = copy.deepcopy(repre["files"])
else:
repre_files_to_convert = [repre["files"]]
added_representations = False
added_review = False
# Process each output definition
for output_def in profile["outputs"]:
# Local copy to avoid accidental mutable changes
files_to_convert = list(repre_files_to_convert)
output_name = output_def["name"]
new_repre = copy.deepcopy(repre)
original_staging_dir = new_repre["stagingDir"]
new_staging_dir = get_temp_dir(
project_name=instance.context.data["projectName"],
use_local_temp=True,
)
new_repre["stagingDir"] = new_staging_dir
output_extension = output_def["extension"]
output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre,
files_to_convert,
output_name,
output_extension)
sequence_files = self._translate_to_sequence(files_to_convert)
self.log.debug("Files to convert: {}".format(sequence_files))
for file_name in sequence_files:
if isinstance(file_name, clique.Collection):
# Convert to filepath that can be directly converted
# by oiio like `frame.1001-1025%04d.exr`
file_name: str = file_name.format(
"{head}{range}{padding}{tail}"
)
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
# TODO: Support formatting with dynamic keys from the
# representation, like e.g. colorspace config, display,
# view, etc.
input_arguments: list[str] = output_def.get(
"input_arguments", []
)
output_arguments: list[str] = output_def.get(
"output_arguments", []
)
# Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args(
"oiiotool",
*input_arguments,
input_path,
*output_arguments,
"-o",
output_path
)
self.log.debug(
"Conversion command: {}".format(" ".join(oiio_cmd)))
run_subprocess(oiio_cmd, logger=self.log)
# cleanup temporary transcoded files
for file_name in new_repre["files"]:
transcoded_file_path = os.path.join(new_staging_dir,
file_name)
instance.context.data["cleanupFullPaths"].append(
transcoded_file_path)
custom_tags = output_def.get("custom_tags")
if custom_tags:
if new_repre.get("custom_tags") is None:
new_repre["custom_tags"] = []
new_repre["custom_tags"].extend(custom_tags)
# Add additional tags from output definition to representation
if new_repre.get("tags") is None:
new_repre["tags"] = []
for tag in output_def["tags"]:
if tag not in new_repre["tags"]:
new_repre["tags"].append(tag)
if tag == "review":
added_review = True
# If there is only 1 file outputted then convert list to
# string, because that'll indicate that it is not a sequence.
if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0]
# If the source representation has "review" tag, but it's not
# part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and
# their outputs will clash in integration.
if "review" in repre.get("tags", []):
added_review = True
new_representations.append(new_repre)
added_representations = True
if added_representations:
self._mark_original_repre_for_deletion(
repre, profile, added_review
)
tags = repre.get("tags") or []
if "delete" in tags and "thumbnail" not in tags:
instance.data["representations"].remove(repre)
instance.data["representations"].extend(new_representations)
def _rename_in_representation(self, new_repre, files_to_convert,
output_name, output_extension):
"""Replace old extension with new one everywhere in representation.
Args:
new_repre (dict)
files_to_convert (list): of filenames from repre["files"],
standardized to always list
output_name (str): key of output definition from Settings,
if "<passthrough>" token used, keep original repre name
output_extension (str): extension from output definition
"""
if output_name != "passthrough":
new_repre["name"] = output_name
if not output_extension:
return
new_repre["ext"] = output_extension
new_repre["outputName"] = output_name
renamed_files = []
for file_name in files_to_convert:
file_name, _ = os.path.splitext(file_name)
file_name = '{}.{}'.format(file_name,
output_extension)
renamed_files.append(file_name)
new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert):
"""Returns original list or a clique.Collection of a sequence.
Uses clique to find frame sequence Collection.
If sequence not found, it returns original list.
Args:
files_to_convert (list): list of file names
Returns:
list[str | clique.Collection]: List of filepaths or a list
of Collections (usually one, unless there are holes)
"""
pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble(
files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True)
if collections:
if len(collections) > 1:
raise ValueError(
"Too many collections {}".format(collections))
collection = collections[0]
# TODO: Technically oiiotool supports holes in the sequence as well
# using the dedicated --frames argument to specify the frames.
# We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Separate the collection so that we have no holes/gaps per
# collection.
return collection.separate()
return files_to_convert
def _get_output_file_path(self, input_path, output_dir,
output_extension):
"""Create output file name path."""
file_name = os.path.basename(input_path)
file_name, input_extension = os.path.splitext(file_name)
if not output_extension:
output_extension = input_extension.replace(".", "")
new_file_name = '{}.{}'.format(file_name,
output_extension)
return os.path.join(output_dir, new_file_name)
def _get_profile(
self,
instance: pyblish.api.Instance,
repre: dict
) -> Optional[dict[str, Any]]:
"""Returns profile if it should process this instance."""
host_name = instance.context.data["hostName"]
product_type = instance.data["productType"]
product_name = instance.data["productName"]
task_data = instance.data["anatomyData"].get("task", {})
task_name = task_data.get("name")
task_type = task_data.get("type")
repre_name: str = repre["name"]
repre_ext: str = repre["ext"]
filtering_criteria = {
"host_names": host_name,
"product_types": product_type,
"product_names": product_name,
"task_names": task_name,
"task_types": task_type,
"representation_names": repre_name,
"representation_exts": repre_ext,
}
profile = filter_profiles(self.profiles, filtering_criteria,
logger=self.log)
if not profile:
self.log.debug(
"Skipped instance. None of profiles in presets are for"
f" Host: \"{host_name}\" |"
f" Product types: \"{product_type}\" |"
f" Product names: \"{product_name}\" |"
f" Task name \"{task_name}\" |"
f" Task type \"{task_type}\" |"
f" Representation: \"{repre_name}\" (.{repre_ext})"
)
return profile
def _repre_is_valid(self, repre: dict) -> bool:
"""Validation if representation should be processed.
Args:
repre (dict): Representation which should be checked.
Returns:
bool: False if can't be processed else True.
"""
if repre.get("ext") not in self.supported_exts:
self.log.debug((
"Representation '{}' has unsupported extension: '{}'. Skipped."
).format(repre["name"], repre.get("ext")))
return False
if not repre.get("files"):
self.log.debug((
"Representation '{}' has empty files. Skipped."
).format(repre["name"]))
return False
if "delete" in repre.get("tags", []):
self.log.debug((
"Representation '{}' has 'delete' tag. Skipped."
).format(repre["name"]))
return False
return True
def _mark_original_repre_for_deletion(
self,
repre: dict,
profile: dict,
added_review: bool
):
"""If new transcoded representation created, delete old."""
if not repre.get("tags"):
repre["tags"] = []
delete_original = profile["delete_original"]
if delete_original:
if "delete" not in repre["tags"]:
repre["tags"].append("delete")
if added_review and "review" in repre["tags"]:
repre["tags"].remove("review")

View file

@ -1,12 +1,83 @@
import collections
import hashlib
import os
import tempfile
import uuid
from pathlib import Path
import pyblish
from ayon_core.lib import get_ffmpeg_tool_args, run_subprocess
from ayon_core.lib import (
get_ffmpeg_tool_args,
run_subprocess
)
def get_audio_instances(context):
"""Return only instances which are having audio in families
Args:
context (pyblish.context): context of publisher
Returns:
list: list of selected instances
"""
audio_instances = []
for instance in context:
if not instance.data.get("parent_instance_id"):
continue
if (
instance.data["productType"] == "audio"
or instance.data.get("reviewAudio")
):
audio_instances.append(instance)
return audio_instances
def map_instances_by_parent_id(context):
"""Create a mapping of instances by their parent id
Args:
context (pyblish.context): context of publisher
Returns:
dict: mapping of instances by their parent id
"""
instances_by_parent_id = collections.defaultdict(list)
for instance in context:
parent_instance_id = instance.data.get("parent_instance_id")
if not parent_instance_id:
continue
instances_by_parent_id[parent_instance_id].append(instance)
return instances_by_parent_id
class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin):
"""Collect audio instance attribute"""
order = pyblish.api.CollectorOrder
label = "Collect Audio Instance Attribute"
def process(self, context):
audio_instances = get_audio_instances(context)
# no need to continue if no audio instances found
if not audio_instances:
return
# create mapped instances by parent id
instances_by_parent_id = map_instances_by_parent_id(context)
# distribute audio related attribute
for audio_instance in audio_instances:
parent_instance_id = audio_instance.data["parent_instance_id"]
for sibl_instance in instances_by_parent_id[parent_instance_id]:
# exclude the same audio instance
if sibl_instance.id == audio_instance.id:
continue
self.log.info(
"Adding audio to Sibling instance: "
f"{sibl_instance.data['label']}"
)
sibl_instance.data["audio"] = None
class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
@ -19,7 +90,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.44
label = "Extract OTIO Audio Tracks"
hosts = ["hiero", "resolve", "flame"]
temp_dir_path = None
def process(self, context):
"""Convert otio audio track's content to audio representations
@ -28,13 +100,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
context (pyblish.Context): context of publisher
"""
# split the long audio file to peces devided by isntances
audio_instances = self.get_audio_instances(context)
self.log.debug("Audio instances: {}".format(len(audio_instances)))
audio_instances = get_audio_instances(context)
if len(audio_instances) < 1:
self.log.info("No audio instances available")
# no need to continue if no audio instances found
if not audio_instances:
return
self.log.debug("Audio instances: {}".format(len(audio_instances)))
# get sequence
otio_timeline = context.data["otioTimeline"]
@ -44,8 +117,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
if not audio_inputs:
return
# temp file
audio_temp_fpath = self.create_temp_file("audio")
# Convert all available audio into single file for trimming
audio_temp_fpath = self.create_temp_file("timeline_audio_track")
# create empty audio with longest duration
empty = self.create_empty(audio_inputs)
@ -59,19 +132,25 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
# remove empty
os.remove(empty["mediaPath"])
# create mapped instances by parent id
instances_by_parent_id = map_instances_by_parent_id(context)
# cut instance framerange and add to representations
self.add_audio_to_instances(audio_temp_fpath, audio_instances)
self.add_audio_to_instances(
audio_temp_fpath, audio_instances, instances_by_parent_id)
# remove full mixed audio file
os.remove(audio_temp_fpath)
def add_audio_to_instances(self, audio_file, instances):
def add_audio_to_instances(
self, audio_file, audio_instances, instances_by_parent_id):
created_files = []
for inst in instances:
name = inst.data["folderPath"]
for audio_instance in audio_instances:
folder_path = audio_instance.data["folderPath"]
file_suffix = folder_path.replace("/", "-")
recycling_file = [f for f in created_files if name in f]
audio_clip = inst.data["otioClip"]
recycling_file = [f for f in created_files if file_suffix in f]
audio_clip = audio_instance.data["otioClip"]
audio_range = audio_clip.range_in_parent()
duration = audio_range.duration.to_frames()
@ -84,68 +163,70 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
start_sec = relative_start_time.to_seconds()
duration_sec = audio_range.duration.to_seconds()
# temp audio file
audio_fpath = self.create_temp_file(name)
# shot related audio file
shot_audio_fpath = self.create_temp_file(file_suffix)
cmd = get_ffmpeg_tool_args(
"ffmpeg",
"-ss", str(start_sec),
"-t", str(duration_sec),
"-i", audio_file,
audio_fpath
shot_audio_fpath
)
# run subprocess
self.log.debug("Executing: {}".format(" ".join(cmd)))
run_subprocess(cmd, logger=self.log)
else:
audio_fpath = recycling_file.pop()
if "audio" in (
inst.data["families"] + [inst.data["productType"]]
):
# add generated audio file to created files for recycling
if shot_audio_fpath not in created_files:
created_files.append(shot_audio_fpath)
else:
shot_audio_fpath = recycling_file.pop()
# audio file needs to be published as representation
if audio_instance.data["productType"] == "audio":
# create empty representation attr
if "representations" not in inst.data:
inst.data["representations"] = []
if "representations" not in audio_instance.data:
audio_instance.data["representations"] = []
# add to representations
inst.data["representations"].append({
"files": os.path.basename(audio_fpath),
audio_instance.data["representations"].append({
"files": os.path.basename(shot_audio_fpath),
"name": "wav",
"ext": "wav",
"stagingDir": os.path.dirname(audio_fpath),
"stagingDir": os.path.dirname(shot_audio_fpath),
"frameStart": 0,
"frameEnd": duration
})
elif "reviewAudio" in inst.data.keys():
audio_attr = inst.data.get("audio") or []
# audio file needs to be reviewable too
elif "reviewAudio" in audio_instance.data.keys():
audio_attr = audio_instance.data.get("audio") or []
audio_attr.append({
"filename": audio_fpath,
"filename": shot_audio_fpath,
"offset": 0
})
inst.data["audio"] = audio_attr
audio_instance.data["audio"] = audio_attr
# add generated audio file to created files for recycling
if audio_fpath not in created_files:
created_files.append(audio_fpath)
def get_audio_instances(self, context):
"""Return only instances which are having audio in families
Args:
context (pyblish.context): context of publisher
Returns:
list: list of selected instances
"""
return [
_i for _i in context
# filter only those with audio product type or family
# and also with reviewAudio data key
if bool("audio" in (
_i.data.get("families", []) + [_i.data["productType"]])
) or _i.data.get("reviewAudio")
]
# Make sure if the audio instance is having siblink instances
# which needs audio for reviewable media so it is also added
# to its instance data
# Retrieve instance data from parent instance shot instance.
parent_instance_id = audio_instance.data["parent_instance_id"]
for sibl_instance in instances_by_parent_id[parent_instance_id]:
# exclude the same audio instance
if sibl_instance.id == audio_instance.id:
continue
self.log.info(
"Adding audio to Sibling instance: "
f"{sibl_instance.data['label']}"
)
audio_attr = sibl_instance.data.get("audio") or []
audio_attr.append({
"filename": shot_audio_fpath,
"offset": 0
})
sibl_instance.data["audio"] = audio_attr
def get_audio_track_items(self, otio_timeline):
"""Get all audio clips form OTIO audio tracks
@ -321,19 +402,23 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
os.remove(filters_tmp_filepath)
def create_temp_file(self, name):
def create_temp_file(self, file_suffix):
"""Create temp wav file
Args:
name (str): name to be used in file name
file_suffix (str): name to be used in file name
Returns:
str: temp fpath
"""
name = name.replace("/", "_")
return os.path.normpath(
tempfile.mktemp(
prefix="pyblish_tmp_{}_".format(name),
suffix=".wav"
)
)
extension = ".wav"
# get 8 characters
hash = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()[:8]
file_name = f"{hash}_{file_suffix}{extension}"
if not self.temp_dir_path:
audio_temp_dir_path = tempfile.mkdtemp(prefix="AYON_audio_")
self.temp_dir_path = Path(audio_temp_dir_path)
self.temp_dir_path.mkdir(parents=True, exist_ok=True)
return (self.temp_dir_path / file_name).as_posix()

View file

@ -163,12 +163,15 @@ class ExtractReview(pyblish.api.InstancePlugin):
"flame",
"unreal",
"batchdelivery",
"photoshop"
"photoshop",
"substancepainter",
]
settings_category = "core"
# Supported extensions
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
image_exts = {
"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif", "psd"
}
video_exts = {"mov", "mp4"}
supported_exts = image_exts | video_exts
@ -361,14 +364,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not filtered_output_defs:
self.log.debug((
"Repre: {} - All output definitions were filtered"
" out by single frame filter. Skipping"
" out by single frame filter. Skipped."
).format(repre["name"]))
continue
# Skip if file is not set
if first_input_path is None:
self.log.warning((
"Representation \"{}\" have empty files. Skipped."
"Representation \"{}\" has empty files. Skipped."
).format(repre["name"]))
continue
@ -400,6 +403,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
new_staging_dir,
self.log
)
# The OIIO conversion will remap the RGBA channels just to
# `R,G,B,A` so we will pass the intermediate file to FFMPEG
# without layer name.
layer_name = ""
try:
self._render_output_definitions(

View file

@ -1,8 +1,9 @@
import copy
from dataclasses import dataclass, field, fields
import os
import subprocess
import tempfile
import re
from typing import Dict, Any, List, Tuple, Optional
import pyblish.api
from ayon_core.lib import (
@ -15,8 +16,10 @@ from ayon_core.lib import (
path_to_subprocess_arg,
run_subprocess,
filter_profiles,
)
from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert,
get_oiio_input_and_channel_args,
get_oiio_info_for_input,
@ -25,6 +28,61 @@ from ayon_core.lib.transcoding import (
from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
@dataclass
class ThumbnailDef:
"""
Data class representing the full configuration for selected profile
Any change of controllable fields in Settings must propagate here!
"""
integrate_thumbnail: bool = False
target_size: Dict[str, Any] = field(
default_factory=lambda: {
"type": "source",
"resize": {"width": 1920, "height": 1080},
}
)
duration_split: float = 0.5
oiiotool_defaults: Dict[str, str] = field(
default_factory=lambda: {
"type": "colorspace",
"colorspace": "color_picking"
}
)
ffmpeg_args: Dict[str, List[Any]] = field(
default_factory=lambda: {"input": [], "output": []}
)
# Background color defined as (R, G, B, A) tuple.
# Note: Use float for alpha channel (0.0 to 1.0).
background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ThumbnailDef":
"""
Creates a ThumbnailDef instance from a dictionary, safely ignoring
any keys in the dictionary that are not fields in the dataclass.
Args:
data (Dict[str, Any]): The dictionary containing configuration data
Returns:
MediaConfig: A new instance of the dataclass.
"""
# Get all field names defined in the dataclass
field_names = {f.name for f in fields(cls)}
# Filter the input dictionary to include only keys matching field names
filtered_data = {k: v for k, v in data.items() if k in field_names}
# Unpack the filtered dictionary into the constructor
return cls(**filtered_data)
class ExtractThumbnail(pyblish.api.InstancePlugin):
"""Create jpg thumbnail from sequence using ffmpeg"""
@ -51,30 +109,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
settings_category = "core"
enabled = False
integrate_thumbnail = False
target_size = {
"type": "source",
"resize": {
"width": 1920,
"height": 1080
}
}
background_color = (0, 0, 0, 0.0)
duration_split = 0.5
# attribute presets from settings
oiiotool_defaults = {
"type": "colorspace",
"colorspace": "color_picking",
"display_and_view": {
"display": "default",
"view": "sRGB"
}
}
ffmpeg_args = {
"input": [],
"output": []
}
product_names = []
profiles = []
def process(self, instance):
# run main process
@ -97,6 +132,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
instance.data["representations"].remove(repre)
def _main_process(self, instance):
if not self.profiles:
self.log.debug("No profiles present for extract review thumbnail.")
return
thumbnail_def = self._get_config_from_profile(instance)
if not thumbnail_def:
return
product_name = instance.data["productName"]
instance_repres = instance.data.get("representations")
if not instance_repres:
@ -129,24 +171,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
self.log.debug("Skipping crypto passes.")
return
# We only want to process the produces needed from settings.
def validate_string_against_patterns(input_str, patterns):
for pattern in patterns:
if re.match(pattern, input_str):
return True
return False
product_names = self.product_names
if product_names:
result = validate_string_against_patterns(
product_name, product_names
)
if not result:
self.log.debug((
"Product name \"{}\" did not match settings filters: {}"
).format(product_name, product_names))
return
# first check for any explicitly marked representations for thumbnail
explicit_repres = self._get_explicit_repres_for_thumbnail(instance)
if explicit_repres:
@ -191,7 +215,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
file_path = self._create_frame_from_video(
video_file_path,
dst_staging
dst_staging,
thumbnail_def
)
if file_path:
src_staging, input_file = os.path.split(file_path)
@ -204,7 +229,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
if "slate-frame" in repre.get("tags", []):
repre_files_thumb = repre_files_thumb[1:]
file_index = int(
float(len(repre_files_thumb)) * self.duration_split)
float(len(repre_files_thumb)) * thumbnail_def.duration_split # noqa: E501
)
input_file = repre_files[file_index]
full_input_path = os.path.join(src_staging, input_file)
@ -233,7 +259,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
repre_thumb_created = self._create_colorspace_thumbnail(
full_input_path,
full_output_path,
colorspace_data
colorspace_data,
thumbnail_def,
)
# Try to use FFMPEG if OIIO is not supported or for cases when
@ -241,13 +268,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# colorspace data
if not repre_thumb_created:
repre_thumb_created = self._create_thumbnail_ffmpeg(
full_input_path, full_output_path
full_input_path, full_output_path, thumbnail_def
)
# Skip representation and try next one if wasn't created
if not repre_thumb_created and oiio_supported:
repre_thumb_created = self._create_thumbnail_oiio(
full_input_path, full_output_path
full_input_path, full_output_path, thumbnail_def
)
if not repre_thumb_created:
@ -275,7 +302,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
new_repre_tags = ["thumbnail"]
# for workflows which needs to have thumbnails published as
# separate representations `delete` tag should not be added
if not self.integrate_thumbnail:
if not thumbnail_def.integrate_thumbnail:
new_repre_tags.append("delete")
new_repre = {
@ -374,7 +401,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return review_repres + other_repres
def _is_valid_images_repre(self, repre):
def _is_valid_images_repre(self, repre: dict) -> bool:
"""Check if representation contains valid image files
Args:
@ -394,9 +421,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
def _create_colorspace_thumbnail(
self,
src_path,
dst_path,
colorspace_data,
src_path: str,
dst_path: str,
colorspace_data: dict,
thumbnail_def: ThumbnailDef,
):
"""Create thumbnail using OIIO tool oiiotool
@ -409,12 +437,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
config (dict)
display (Optional[str])
view (Optional[str])
thumbnail_def (ThumbnailDefinition): Thumbnail definition.
Returns:
str: path to created thumbnail
"""
self.log.info("Extracting thumbnail {}".format(dst_path))
resolution_arg = self._get_resolution_arg("oiiotool", src_path)
self.log.info(f"Extracting thumbnail {dst_path}")
resolution_arg = self._get_resolution_args(
"oiiotool", src_path, thumbnail_def
)
repre_display = colorspace_data.get("display")
repre_view = colorspace_data.get("view")
@ -433,12 +464,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
# if representation doesn't have display and view then use
# oiiotool_defaults
elif self.oiiotool_defaults:
oiio_default_type = self.oiiotool_defaults["type"]
elif thumbnail_def.oiiotool_defaults:
oiiotool_defaults = thumbnail_def.oiiotool_defaults
oiio_default_type = oiiotool_defaults["type"]
if "colorspace" == oiio_default_type:
oiio_default_colorspace = self.oiiotool_defaults["colorspace"]
oiio_default_colorspace = oiiotool_defaults["colorspace"]
else:
display_and_view = self.oiiotool_defaults["display_and_view"]
display_and_view = oiiotool_defaults["display_and_view"]
oiio_default_display = display_and_view["display"]
oiio_default_view = display_and_view["view"]
@ -465,19 +497,34 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return True
def _create_thumbnail_oiio(self, src_path, dst_path):
def _create_thumbnail_oiio(self, src_path, dst_path, thumbnail_def):
self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}")
try:
resolution_arg = self._get_resolution_arg("oiiotool", src_path)
resolution_arg = self._get_resolution_args(
"oiiotool", src_path, thumbnail_def
)
except RuntimeError:
self.log.warning(
"Failed to create thumbnail using oiio", exc_info=True
)
return False
input_info = get_oiio_info_for_input(src_path, logger=self.log)
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
input_info = get_oiio_info_for_input(
src_path,
logger=self.log,
verbose=False,
)
try:
input_arg, channels_arg = get_oiio_input_and_channel_args(
input_info
)
except MissingRGBAChannelsError:
self.log.debug(
"Unable to find relevant reviewable channel for thumbnail "
"creation"
)
return False
oiio_cmd = get_oiio_tool_args(
"oiiotool",
input_arg, src_path,
@ -500,9 +547,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
return False
def _create_thumbnail_ffmpeg(self, src_path, dst_path):
def _create_thumbnail_ffmpeg(self, src_path, dst_path, thumbnail_def):
try:
resolution_arg = self._get_resolution_arg("ffmpeg", src_path)
resolution_arg = self._get_resolution_args(
"ffmpeg", src_path, thumbnail_def
)
except RuntimeError:
self.log.warning(
"Failed to create thumbnail using ffmpeg", exc_info=True
@ -510,7 +559,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return False
ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg")
ffmpeg_args = self.ffmpeg_args or {}
ffmpeg_args = thumbnail_def.ffmpeg_args or {}
jpeg_items = [
subprocess.list2cmdline(ffmpeg_path_args)
@ -550,7 +599,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
return False
def _create_frame_from_video(self, video_file_path, output_dir):
def _create_frame_from_video(
self,
video_file_path: str,
output_dir: str,
thumbnail_def: ThumbnailDef,
) -> Optional[str]:
"""Convert video file to one frame image via ffmpeg"""
# create output file path
base_name = os.path.basename(video_file_path)
@ -575,7 +629,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
seek_position = 0.0
# Only use timestamp calculation for videos longer than 0.1 seconds
if duration > 0.1:
seek_position = duration * self.duration_split
seek_position = duration * thumbnail_def.duration_split
# Build command args
cmd_args = []
@ -649,16 +703,17 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
):
os.remove(output_thumb_file_path)
def _get_resolution_arg(
def _get_resolution_args(
self,
application,
input_path,
):
application: str,
input_path: str,
thumbnail_def: ThumbnailDef,
) -> list:
# get settings
if self.target_size["type"] == "source":
if thumbnail_def.target_size["type"] == "source":
return []
resize = self.target_size["resize"]
resize = thumbnail_def.target_size["resize"]
target_width = resize["width"]
target_height = resize["height"]
@ -668,6 +723,43 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
input_path,
target_width,
target_height,
bg_color=self.background_color,
bg_color=thumbnail_def.background_color,
log=self.log
)
def _get_config_from_profile(
self,
instance: pyblish.api.Instance
) -> Optional[ThumbnailDef]:
"""Returns profile if and how repre should be color transcoded."""
host_name = instance.context.data["hostName"]
product_type = instance.data["productType"]
product_name = instance.data["productName"]
task_data = instance.data["anatomyData"].get("task", {})
task_name = task_data.get("name")
task_type = task_data.get("type")
filtering_criteria = {
"host_names": host_name,
"product_types": product_type,
"product_names": product_name,
"task_names": task_name,
"task_types": task_type,
}
profile = filter_profiles(
self.profiles,
filtering_criteria,
logger=self.log
)
if not profile:
self.log.debug(
"Skipped instance. None of profiles in presets are for"
f' Host: "{host_name}"'
f' | Product types: "{product_type}"'
f' | Product names: "{product_name}"'
f' | Task name "{task_name}"'
f' | Task type "{task_type}"'
)
return None
return ThumbnailDef.from_dict(profile)

View file

@ -14,6 +14,7 @@ Todos:
import os
import tempfile
from typing import List, Optional
import pyblish.api
from ayon_core.lib import (
@ -22,6 +23,7 @@ from ayon_core.lib import (
is_oiio_supported,
run_subprocess,
get_rescaled_command_arguments,
)
@ -31,17 +33,20 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
Thumbnail source must be a single image or video filepath.
"""
label = "Extract Thumbnail (from source)"
label = "Extract Thumbnail from source"
# Before 'ExtractThumbnail' in global plugins
order = pyblish.api.ExtractorOrder - 0.00001
def process(self, instance):
# Settings
target_size = {
"type": "resize",
"resize": {"width": 1920, "height": 1080}
}
background_color = (0, 0, 0, 0.0)
def process(self, instance: pyblish.api.Instance):
self._create_context_thumbnail(instance.context)
product_name = instance.data["productName"]
self.log.debug(
"Processing instance with product name {}".format(product_name)
)
thumbnail_source = instance.data.get("thumbnailSource")
if not thumbnail_source:
self.log.debug("Thumbnail source not filled. Skipping.")
@ -69,6 +74,8 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"outputName": "thumbnail",
}
new_repre["tags"].append("delete")
# adding representation
self.log.debug(
"Adding thumbnail representation: {}".format(new_repre)
@ -76,7 +83,11 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
instance.data["representations"].append(new_repre)
instance.data["thumbnailPath"] = dst_filepath
def _create_thumbnail(self, context, thumbnail_source):
def _create_thumbnail(
self,
context: pyblish.api.Context,
thumbnail_source: str,
) -> Optional[str]:
if not thumbnail_source:
self.log.debug("Thumbnail source not filled. Skipping.")
return
@ -131,7 +142,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
self.log.warning("Thumbnail has not been created.")
def _instance_has_thumbnail(self, instance):
def _instance_has_thumbnail(self, instance: pyblish.api.Instance) -> bool:
if "representations" not in instance.data:
self.log.warning(
"Instance does not have 'representations' key filled"
@ -143,14 +154,29 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
return True
return False
def create_thumbnail_oiio(self, src_path, dst_path):
def create_thumbnail_oiio(
self,
src_path: str,
dst_path: str,
) -> bool:
self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path))
oiio_cmd = get_oiio_tool_args(
"oiiotool",
"-a", src_path,
"--ch", "R,G,B",
"-o", dst_path
)
try:
resolution_args = self._get_resolution_args(
"oiiotool", src_path
)
except Exception:
self.log.warning("Failed to get resolution args for OIIO.")
return False
oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path)
if resolution_args:
# resize must be before -o
oiio_cmd.extend(resolution_args)
else:
# resize provides own -ch, must be only one
oiio_cmd.extend(["--ch", "R,G,B"])
oiio_cmd.extend(["-o", dst_path])
self.log.debug("Running: {}".format(" ".join(oiio_cmd)))
try:
run_subprocess(oiio_cmd, logger=self.log)
@ -162,7 +188,19 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
)
return False
def create_thumbnail_ffmpeg(self, src_path, dst_path):
def create_thumbnail_ffmpeg(
self,
src_path: str,
dst_path: str,
) -> bool:
try:
resolution_args = self._get_resolution_args(
"ffmpeg", src_path
)
except Exception:
self.log.warning("Failed to get resolution args for ffmpeg.")
return False
max_int = str(2147483647)
ffmpeg_cmd = get_ffmpeg_tool_args(
"ffmpeg",
@ -171,9 +209,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"-probesize", max_int,
"-i", src_path,
"-frames:v", "1",
dst_path
)
ffmpeg_cmd.extend(resolution_args)
# possible resize must be before output args
ffmpeg_cmd.append(dst_path)
self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd)))
try:
run_subprocess(ffmpeg_cmd, logger=self.log)
@ -185,10 +227,37 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
)
return False
def _create_context_thumbnail(self, context):
def _create_context_thumbnail(
self,
context: pyblish.api.Context,
):
if "thumbnailPath" in context.data:
return
thumbnail_source = context.data.get("thumbnailSource")
thumbnail_path = self._create_thumbnail(context, thumbnail_source)
context.data["thumbnailPath"] = thumbnail_path
context.data["thumbnailPath"] = self._create_thumbnail(
context, thumbnail_source
)
def _get_resolution_args(
self,
application: str,
input_path: str,
) -> List[str]:
# get settings
if self.target_size["type"] == "source":
return []
resize = self.target_size["resize"]
target_width = resize["width"]
target_height = resize["height"]
# form arg string per application
return get_rescaled_command_arguments(
application,
input_path,
target_width,
target_height,
bg_color=self.background_color,
log=self.log,
)

View file

@ -1,6 +1,8 @@
from operator import attrgetter
import dataclasses
import os
import platform
from collections import defaultdict
from typing import Any, Dict, List
import pyblish.api
@ -12,10 +14,11 @@ except ImportError:
from ayon_core.lib import (
TextDef,
BoolDef,
NumberDef,
UISeparatorDef,
UILabelDef,
EnumDef,
filter_profiles
filter_profiles,
)
try:
from ayon_core.pipeline.usdlib import (
@ -24,7 +27,8 @@ try:
variant_nested_prim_path,
setup_asset_layer,
add_ordered_sublayer,
set_layer_defaults
set_layer_defaults,
get_standard_default_prim_name
)
except ImportError:
pass
@ -175,10 +179,17 @@ def get_instance_uri_path(
# If for whatever reason we were unable to retrieve from the context
# then get the path from an existing database entry
path = get_representation_path_by_names(**query)
path = get_representation_path_by_names(
anatomy=context.data["anatomy"],
**names
)
if not path:
raise RuntimeError(f"Unable to resolve publish path for: {names}")
# Ensure `None` for now is also a string
path = str(path)
if platform.system().lower() == "windows":
path = path.replace("\\", "/")
return path
@ -266,22 +277,26 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
# the contributions so that we can design a system where custom
# contributions outside the predefined orders are possible to be
# managed. So that if a particular asset requires an extra contribution
# level, you can add itdirectly from the publisher at that particular
# level, you can add it directly from the publisher at that particular
# order. Future publishes will then see the existing contribution and will
# persist adding it to future bootstraps at that order
contribution_layers: Dict[str, int] = {
contribution_layers: Dict[str, Dict[str, int]] = {
# asset layers
"model": 100,
"assembly": 150,
"groom": 175,
"look": 200,
"rig": 300,
"asset": {
"model": 100,
"assembly": 150,
"groom": 175,
"look": 200,
"rig": 300,
},
# shot layers
"layout": 200,
"animation": 300,
"simulation": 400,
"fx": 500,
"lighting": 600,
"shot": {
"layout": 200,
"animation": 300,
"simulation": 400,
"fx": 500,
"lighting": 600,
}
}
# Default profiles to set certain instance attribute defaults based on
# profiles in settings
@ -296,12 +311,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
cls.enabled = plugin_settings.get("enabled", cls.enabled)
# Define contribution layers via settings
contribution_layers = {}
# Define contribution layers via settings by their scope
contribution_layers = defaultdict(dict)
for entry in plugin_settings.get("contribution_layers", []):
contribution_layers[entry["name"]] = int(entry["order"])
for scope in entry.get("scope", []):
contribution_layers[scope][entry["name"]] = int(entry["order"])
if contribution_layers:
cls.contribution_layers = contribution_layers
cls.contribution_layers = dict(contribution_layers)
else:
cls.log.warning(
"No scoped contribution layers found in settings, falling back"
" to CollectUSDLayerContributions plug-in defaults..."
)
cls.profiles = plugin_settings.get("profiles", [])
@ -325,10 +346,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
attr_values[key] = attr_values[key].format(**data)
# Define contribution
order = self.contribution_layers.get(
attr_values["contribution_layer"], 0
)
in_layer_order: int = attr_values.get("contribution_in_layer_order", 0)
if attr_values["contribution_apply_as_variant"]:
contribution = VariantContribution(
instance=instance,
@ -337,19 +355,23 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
variant_set_name=attr_values["contribution_variant_set_name"],
variant_name=attr_values["contribution_variant"],
variant_is_default=attr_values["contribution_variant_is_default"], # noqa: E501
order=order
order=in_layer_order
)
else:
contribution = SublayerContribution(
instance=instance,
layer_id=attr_values["contribution_layer"],
target_product=attr_values["contribution_target_product"],
order=order
order=in_layer_order
)
asset_product = contribution.target_product
layer_product = "{}_{}".format(asset_product, contribution.layer_id)
scope: str = attr_values["contribution_target_product_init"]
layer_order: int = (
self.contribution_layers[scope][attr_values["contribution_layer"]]
)
# Layer contribution instance
layer_instance = self.get_or_create_instance(
product_name=layer_product,
@ -361,7 +383,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
contribution
)
layer_instance.data["usd_layer_id"] = contribution.layer_id
layer_instance.data["usd_layer_order"] = contribution.order
layer_instance.data["usd_layer_order"] = layer_order
layer_instance.data["productGroup"] = (
instance.data.get("productGroup") or "USD Layer"
@ -480,18 +502,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
profile = {}
# Define defaults
default_enabled = profile.get("contribution_enabled", True)
default_enabled: bool = profile.get("contribution_enabled", True)
default_contribution_layer = profile.get(
"contribution_layer", None)
default_apply_as_variant = profile.get(
default_apply_as_variant: bool = profile.get(
"contribution_apply_as_variant", False)
default_target_product = profile.get(
default_target_product: str = profile.get(
"contribution_target_product", "usdAsset")
default_init_as = (
default_init_as: str = (
"asset"
if profile.get("contribution_target_product") == "usdAsset"
else "shot")
init_as_visible = False
init_as_visible = True
# Attributes logic
publish_attributes = instance["publish_attributes"].get(
@ -500,6 +522,12 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
visible = publish_attributes.get("contribution_enabled", True)
variant_visible = visible and publish_attributes.get(
"contribution_apply_as_variant", True)
init_as: str = publish_attributes.get(
"contribution_target_product_init", default_init_as)
contribution_layers = cls.contribution_layers.get(
init_as, {}
)
return [
UISeparatorDef("usd_container_settings1"),
@ -549,9 +577,22 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"predefined ordering.\nA higher order (further down "
"the list) will contribute as a stronger opinion."
),
items=list(cls.contribution_layers.keys()),
items=list(contribution_layers.keys()),
default=default_contribution_layer,
visible=visible),
# TODO: We may want to make the visibility of this optional
# based on studio preference, to avoid complexity when not needed
NumberDef("contribution_in_layer_order",
label="Strength order",
tooltip=(
"The contribution inside the department layer will be "
"made with this offset applied. A higher number means "
"a stronger opinion."
),
default=0,
minimum=-99999,
maximum=99999,
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
tooltip=(
@ -597,7 +638,11 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
# Update attributes if any of the following plug-in attributes
# change:
keys = ["contribution_enabled", "contribution_apply_as_variant"]
keys = {
"contribution_enabled",
"contribution_apply_as_variant",
"contribution_target_product_init",
}
for instance_change in event["changes"]:
instance = instance_change["instance"]
@ -637,6 +682,7 @@ class ExtractUSDLayerContribution(publish.Extractor):
settings_category = "core"
use_ayon_entity_uri = False
enforce_default_prim = False
def process(self, instance):
@ -647,9 +693,18 @@ class ExtractUSDLayerContribution(publish.Extractor):
path = get_last_publish(instance)
if path and BUILD_INTO_LAST_VERSIONS:
sdf_layer = Sdf.Layer.OpenAsAnonymous(path)
# If enabled in settings, ignore any default prim specified on
# older publish versions and always publish with the AYON
# standard default prim
if self.enforce_default_prim:
sdf_layer.defaultPrim = get_standard_default_prim_name(
folder_path
)
default_prim = sdf_layer.defaultPrim
else:
default_prim = folder_path.rsplit("/", 1)[-1] # use folder name
default_prim = get_standard_default_prim_name(folder_path)
sdf_layer = Sdf.Layer.CreateAnonymous()
set_layer_defaults(sdf_layer, default_prim=default_prim)
@ -710,7 +765,7 @@ class ExtractUSDLayerContribution(publish.Extractor):
layer=sdf_layer,
contribution_path=path,
layer_id=product_name,
order=None, # unordered
order=contribution.order,
add_sdf_arguments_metadata=True
)
else:
@ -807,7 +862,7 @@ class ExtractUSDAssetContribution(publish.Extractor):
folder_path = instance.data["folderPath"]
product_name = instance.data["productName"]
self.log.debug(f"Building asset: {folder_path} > {product_name}")
folder_name = folder_path.rsplit("/", 1)[-1]
asset_name = get_standard_default_prim_name(folder_path)
# Contribute layers to asset
# Use existing asset and add to it, or initialize a new asset layer
@ -825,8 +880,9 @@ class ExtractUSDAssetContribution(publish.Extractor):
# If no existing publish of this product exists then we initialize
# the layer as either a default asset or shot structure.
init_type = instance.data["contribution_target_product_init"]
self.log.debug("Initializing layer as type: %s", init_type)
asset_layer, payload_layer = self.init_layer(
asset_name=folder_name, init_type=init_type
asset_name=asset_name, init_type=init_type
)
# Author timeCodesPerSecond and framesPerSecond if the asset layer
@ -906,7 +962,7 @@ class ExtractUSDAssetContribution(publish.Extractor):
payload_layer.Export(payload_path, args={"format": "usda"})
self.add_relative_file(instance, payload_path)
def init_layer(self, asset_name, init_type):
def init_layer(self, asset_name: str, init_type: str):
"""Initialize layer if no previous version exists"""
if init_type == "asset":

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>{upload_type} upload timed out</title>
<description>
## {upload_type} upload failed after retries
The connection to the AYON server timed out while uploading a file.
### How to resolve?
1. Try publishing again. Intermittent network hiccups often resolve on retry.
2. Ensure your network/VPN is stable and large uploads are allowed.
3. If it keeps failing, try again later or contact your admin.
<pre>File: {file}
Error: {error}</pre>
</description>
</error>
</root>

View file

@ -28,6 +28,7 @@ from ayon_core.pipeline.publish import (
KnownPublishError,
get_publish_template_name,
)
from ayon_core.pipeline import is_product_base_type_supported
log = logging.getLogger(__name__)
@ -122,10 +123,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"representation",
"username",
"output",
# OpenPype keys - should be removed
"asset", # folder[name]
"subset", # product[name]
"family", # product[type]
]
def process(self, instance):
@ -367,6 +364,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
folder_entity = instance.data["folderEntity"]
product_name = instance.data["productName"]
product_type = instance.data["productType"]
product_base_type = instance.data.get("productBaseType")
self.log.debug("Product: {}".format(product_name))
# Get existing product if it exists
@ -394,14 +393,33 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
product_id = None
if existing_product_entity:
product_id = existing_product_entity["id"]
product_entity = new_product_entity(
product_name,
product_type,
folder_entity["id"],
data=data,
attribs=attributes,
entity_id=product_id
)
new_product_entity_kwargs = {
"name": product_name,
"product_type": product_type,
"folder_id": folder_entity["id"],
"data": data,
"attribs": attributes,
"entity_id": product_id,
"product_base_type": product_base_type,
}
if not is_product_base_type_supported():
new_product_entity_kwargs.pop("product_base_type")
if (
product_base_type is not None
and product_base_type != product_type):
self.log.warning((
"Product base type %s is not supported by the server, "
"but it's defined - and it differs from product type %s. "
"Using product base type as product type."
), product_base_type, product_type)
new_product_entity_kwargs["product_type"] = (
product_base_type
)
product_entity = new_product_entity(**new_product_entity_kwargs)
if existing_product_entity is None:
# Create a new product
@ -457,6 +475,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
else:
version_data[key] = value
host_name = instance.context.data["hostName"]
version_data["host_name"] = host_name
version_entity = new_version_entity(
version_number,
product_entity["id"],
@ -899,8 +920,12 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# Include optional data if present in
optionals = [
"frameStart", "frameEnd", "step",
"handleEnd", "handleStart", "sourceHashes"
"frameStart", "frameEnd",
"handleEnd", "handleStart",
"step",
"resolutionWidth", "resolutionHeight",
"pixelAspect",
"sourceHashes"
]
for key in optionals:
if key in instance.data:
@ -924,6 +949,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
host_name = context.data["hostName"]
anatomy_data = instance.data["anatomyData"]
product_type = instance.data["productType"]
product_base_type = instance.data.get("productBaseType")
task_info = anatomy_data.get("task") or {}
return get_publish_template_name(
@ -933,7 +959,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
task_name=task_info.get("name"),
task_type=task_info.get("type"),
project_settings=context.data["project_settings"],
logger=self.log
logger=self.log,
product_base_type=product_base_type
)
def get_rootless_path(self, anatomy, path):

View file

@ -1,11 +1,8 @@
import os
import sys
import copy
import errno
import itertools
import shutil
from concurrent.futures import ThreadPoolExecutor
from speedcopy import copyfile
import clique
import pyblish.api
@ -16,11 +13,15 @@ from ayon_api.operations import (
)
from ayon_api.utils import create_entity_id
from ayon_core.lib import create_hard_link, source_hash
from ayon_core.lib.file_transaction import wait_for_future_errors
from ayon_core.lib import source_hash
from ayon_core.lib.file_transaction import (
FileTransaction,
DuplicateDestinationError,
)
from ayon_core.pipeline.publish import (
get_publish_template_name,
OptionalPyblishPluginMixin,
KnownPublishError,
)
@ -81,12 +82,9 @@ class IntegrateHeroVersion(
db_representation_context_keys = [
"project",
"folder",
"asset",
"hierarchy",
"task",
"product",
"subset",
"family",
"representation",
"username",
"output"
@ -424,19 +422,40 @@ class IntegrateHeroVersion(
(repre_entity, dst_paths)
)
self.path_checks = []
file_transactions = FileTransaction(
log=self.log,
# Enforce unique transfers
allow_queue_replacements=False
)
mode = FileTransaction.MODE_COPY
if self.use_hardlinks:
mode = FileTransaction.MODE_LINK
# Copy(hardlink) paths of source and destination files
# TODO should we *only* create hardlinks?
# TODO should we keep files for deletion until this is successful?
with ThreadPoolExecutor(max_workers=8) as executor:
futures = [
executor.submit(self.copy_file, src_path, dst_path)
for src_path, dst_path in itertools.chain(
src_to_dst_file_paths, other_file_paths_mapping
)
]
wait_for_future_errors(executor, futures)
try:
for src_path, dst_path in itertools.chain(
src_to_dst_file_paths,
other_file_paths_mapping
):
file_transactions.add(src_path, dst_path, mode=mode)
self.log.debug("Integrating source files to destination ...")
file_transactions.process()
except DuplicateDestinationError as exc:
# Raise DuplicateDestinationError as KnownPublishError
# and rollback the transactions
file_transactions.rollback()
raise KnownPublishError(exc).with_traceback(sys.exc_info()[2])
except Exception as exc:
# Rollback the transactions
file_transactions.rollback()
self.log.critical("Error when copying files", exc_info=True)
raise exc
# Finalizing can't rollback safely so no use for moving it to
# the try, except.
file_transactions.finalize()
# Update prepared representation etity data with files
# and integrate it to server.
@ -625,48 +644,6 @@ class IntegrateHeroVersion(
).format(path))
return path
def copy_file(self, src_path, dst_path):
# TODO check drives if are the same to check if cas hardlink
dirname = os.path.dirname(dst_path)
try:
os.makedirs(dirname)
self.log.debug("Folder(s) created: \"{}\"".format(dirname))
except OSError as exc:
if exc.errno != errno.EEXIST:
self.log.error("An unexpected error occurred.", exc_info=True)
raise
self.log.debug("Folder already exists: \"{}\"".format(dirname))
if self.use_hardlinks:
# First try hardlink and copy if paths are cross drive
self.log.debug("Hardlinking file \"{}\" to \"{}\"".format(
src_path, dst_path
))
try:
create_hard_link(src_path, dst_path)
# Return when successful
return
except OSError as exc:
# re-raise exception if different than
# EXDEV - cross drive path
# EINVAL - wrong format, must be NTFS
self.log.debug(
"Hardlink failed with errno:'{}'".format(exc.errno))
if exc.errno not in [errno.EXDEV, errno.EINVAL]:
raise
self.log.debug(
"Hardlinking failed, falling back to regular copy...")
self.log.debug("Copying file \"{}\" to \"{}\"".format(
src_path, dst_path
))
copyfile(src_path, dst_path)
def version_from_representations(self, project_name, repres):
for repre in repres:
version = ayon_api.get_version_by_id(

View file

@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
created links by its type
"""
if workfile_instance is None:
self.log.warning("No workfile in this publish session.")
self.log.debug("No workfile in this publish session.")
return
workfile_version_id = workfile_instance.data["versionEntity"]["id"]

View file

@ -62,10 +62,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin):
product_type = instance.data["productType"]
fill_pairs = prepare_template_data({
"family": product_type,
"task": filter_criteria["tasks"],
"host": filter_criteria["hosts"],
"subset": product_name,
"product": {
"name": product_name,
"type": product_type,

View file

@ -1,11 +1,17 @@
import os
import time
import pyblish.api
import ayon_api
from ayon_api import TransferProgress
from ayon_api.server_api import RequestTypes
import pyblish.api
from ayon_core.lib import get_media_mime_type
from ayon_core.pipeline.publish import get_publish_repre_path
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import (
PublishXmlValidationError,
get_publish_repre_path,
)
import requests.exceptions
class IntegrateAYONReview(pyblish.api.InstancePlugin):
@ -44,7 +50,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
if "webreview" not in repre_tags:
continue
# exclude representations with are going to be published on farm
# exclude representations going to be published on farm
if "publish_on_farm" in repre_tags:
continue
@ -75,18 +81,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
f"/projects/{project_name}"
f"/versions/{version_id}/reviewables{query}"
)
filename = os.path.basename(repre_path)
# Upload the reviewable
self.log.info(f"Uploading reviewable '{label or filename}' ...")
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
self.log.info(f"Uploading reviewable {repre_path}")
ayon_con.upload_file(
# Upload with retries and clear help if it keeps failing
self._upload_with_retries(
ayon_con,
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
content_type,
)
def _get_review_label(self, repre, uploaded_labels):
@ -100,3 +101,74 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
idx += 1
label = f"{orig_label}_{idx}"
return label
def _upload_with_retries(
self,
ayon_con: ayon_api.ServerAPI,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
filename = os.path.basename(repre_path)
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of reviewable timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Review",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -24,11 +24,16 @@
import os
import collections
import time
import pyblish.api
import ayon_api
from ayon_api import RequestTypes
from ayon_api import RequestTypes, TransferProgress
from ayon_api.operations import OperationsSession
import pyblish.api
import requests
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import PublishXmlValidationError
InstanceFilterResult = collections.namedtuple(
@ -164,25 +169,17 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
return os.path.normpath(filled_path)
def _create_thumbnail(self, project_name: str, src_filepath: str) -> str:
"""Upload thumbnail to AYON and return its id.
This is temporary fix of 'create_thumbnail' function in ayon_api to
fix jpeg mime type.
"""
mime_type = None
with open(src_filepath, "rb") as stream:
if b"\xff\xd8\xff" == stream.read(3):
mime_type = "image/jpeg"
"""Upload thumbnail to AYON and return its id."""
mime_type = get_media_mime_type(src_filepath)
if mime_type is None:
return ayon_api.create_thumbnail(project_name, src_filepath)
return ayon_api.create_thumbnail(
project_name, src_filepath
)
response = ayon_api.upload_file(
response = self._upload_with_retries(
f"projects/{project_name}/thumbnails",
src_filepath,
request_type=RequestTypes.post,
headers={"Content-Type": mime_type},
mime_type,
)
response.raise_for_status()
return response.json()["id"]
@ -248,3 +245,71 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
or instance.data.get("name")
or "N/A"
)
def _upload_with_retries(
self,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
ayon_con = ayon_api.get_server_api_connection()
headers = ayon_con.get_headers(content_type)
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of thumbnail timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Thumbnail",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -969,12 +969,6 @@ SearchItemDisplayWidget #ValueWidget {
background: {color:bg-buttons};
}
/* Subset Manager */
#SubsetManagerDetailsText {}
#SubsetManagerDetailsText[state="invalid"] {
border: 1px solid #ff0000;
}
/* Creator */
#CreatorsView::item {
padding: 1px 5px;

View file

@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.addWidget(attrs_widget, 0)
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)

View file

@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
layout.deleteLater()
new_layout = QtWidgets.QGridLayout()
new_layout.setContentsMargins(0, 0, 0, 0)
new_layout.setColumnStretch(0, 0)
new_layout.setColumnStretch(1, 1)
self.setLayout(new_layout)
@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
if not attr_def.visible:
continue
col_num = 0
expand_cols = 2
if attr_def.is_value_def and attr_def.is_label_horizontal:
expand_cols = 1
col_num = 2 - expand_cols
if attr_def.is_value_def and attr_def.label:
label_widget = AttributeDefinitionsLabel(
attr_def.id, attr_def.label, self
@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
| QtCore.Qt.AlignVCenter
)
layout.addWidget(
label_widget, row, 0, 1, expand_cols
label_widget, row, col_num, 1, 1
)
if not attr_def.is_label_horizontal:
if attr_def.is_label_horizontal:
col_num += 1
expand_cols = 1
else:
row += 1
if attr_def.is_value_def:

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import json
import contextlib
from abc import ABC, abstractmethod
from typing import Any, Optional
from dataclasses import dataclass
import ayon_api
from ayon_api.graphql_queries import projects_graphql_query
from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem
@ -275,7 +277,7 @@ class ProductTypeIconMapping:
return self._definitions_by_name
def _get_project_items_from_entitiy(
def _get_project_items_from_entity(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
@ -290,6 +292,7 @@ def _get_project_items_from_entitiy(
return [
ProjectItem.from_entity(project)
for project in projects
if project["active"]
]
@ -538,8 +541,32 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
def _fetch_graphql_projects(self) -> list[dict[str, Any]]:
"""Fetch projects using GraphQl.
This method was added because ayon_api had a bug in 'get_projects'.
Returns:
list[dict[str, Any]]: List of projects.
"""
api = ayon_api.get_server_api_connection()
query = projects_graphql_query({"name", "active", "library", "data"})
projects = []
for parsed_data in query.continuous_query(api):
for project in parsed_data["projects"]:
project_data = project["data"]
if project_data is None:
project["data"] = {}
elif isinstance(project_data, str):
project["data"] = json.loads(project_data)
projects.append(project)
return projects
def _query_projects(self) -> list[ProjectItem]:
projects = ayon_api.get_projects(fields=["name", "active", "library"])
projects = self._fetch_graphql_projects()
user = ayon_api.get_user()
pinned_projects = (
user
@ -548,7 +575,7 @@ class ProjectsModel(object):
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
project_items = _get_project_items_from_entitiy(list(projects))
project_items = _get_project_items_from_entity(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items

View file

@ -1,10 +1,13 @@
import json
import collections
from typing import Optional
import ayon_api
from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict
from ayon_core.lib import NestedCacheItem
from ayon_core.lib import NestedCacheItem, get_ayon_username
NOT_SET = object()
# --- Implementation that should be in ayon-python-api ---
@ -105,9 +108,18 @@ class UserItem:
class UsersModel:
def __init__(self, controller):
self._current_username = NOT_SET
self._controller = controller
self._users_cache = NestedCacheItem(default_factory=list)
def get_current_username(self) -> Optional[str]:
if self._current_username is NOT_SET:
self._current_username = get_ayon_username()
return self._current_username
def reset(self) -> None:
self._users_cache.reset()
def get_user_items(self, project_name):
"""Get user items.

View file

@ -1,6 +1,8 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from typing import Optional
@dataclass
@ -13,8 +15,8 @@ class TabItem:
class InterpreterConfig:
width: Optional[int]
height: Optional[int]
splitter_sizes: List[int] = field(default_factory=list)
tabs: List[TabItem] = field(default_factory=list)
splitter_sizes: list[int] = field(default_factory=list)
tabs: list[TabItem] = field(default_factory=list)
class AbstractInterpreterController(ABC):
@ -27,7 +29,7 @@ class AbstractInterpreterController(ABC):
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
splitter_sizes: list[int],
tabs: list[dict[str, str]],
) -> None:
pass

View file

@ -1,4 +1,5 @@
from typing import List, Dict
from __future__ import annotations
from typing import Optional
from ayon_core.lib import JSONSettingRegistry
from ayon_core.lib.local_settings import get_launcher_local_dir
@ -11,13 +12,15 @@ from .abstract import (
class InterpreterController(AbstractInterpreterController):
def __init__(self):
def __init__(self, name: Optional[str] = None) -> None:
if name is None:
name = "python_interpreter_tool"
self._registry = JSONSettingRegistry(
"python_interpreter_tool",
name,
get_launcher_local_dir(),
)
def get_config(self):
def get_config(self) -> InterpreterConfig:
width = None
height = None
splitter_sizes = []
@ -54,9 +57,9 @@ class InterpreterController(AbstractInterpreterController):
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
splitter_sizes: list[int],
tabs: list[dict[str, str]],
) -> None:
self._registry.set_item("width", width)
self._registry.set_item("height", height)
self._registry.set_item("splitter_sizes", splitter_sizes)

View file

@ -1,42 +1,42 @@
import os
import sys
import collections
class _CustomSTD:
def __init__(self, orig_std, write_callback):
self.orig_std = orig_std
self._valid_orig = bool(orig_std)
self._write_callback = write_callback
def __getattr__(self, attr):
return getattr(self.orig_std, attr)
def __setattr__(self, key, value):
if key in ("orig_std", "_valid_orig", "_write_callback"):
super().__setattr__(key, value)
else:
setattr(self.orig_std, key, value)
def write(self, text):
if self._valid_orig:
self.orig_std.write(text)
self._write_callback(text)
class StdOEWrap:
def __init__(self):
self._origin_stdout_write = None
self._origin_stderr_write = None
self._listening = False
self.lines = collections.deque()
if not sys.stdout:
sys.stdout = open(os.devnull, "w")
if not sys.stderr:
sys.stderr = open(os.devnull, "w")
if self._origin_stdout_write is None:
self._origin_stdout_write = sys.stdout.write
if self._origin_stderr_write is None:
self._origin_stderr_write = sys.stderr.write
self._listening = True
sys.stdout.write = self._stdout_listener
sys.stderr.write = self._stderr_listener
self._stdout_wrap = _CustomSTD(sys.stdout, self._listener)
self._stderr_wrap = _CustomSTD(sys.stderr, self._listener)
sys.stdout = self._stdout_wrap
sys.stderr = self._stderr_wrap
def stop_listen(self):
self._listening = False
def _stdout_listener(self, text):
def _listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stdout_write is not None:
self._origin_stdout_write(text)
def _stderr_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stderr_write is not None:
self._origin_stderr_write(text)

View file

@ -1,10 +1,14 @@
from typing import Optional
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib import Logger
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.addon import AddonsManager
from ayon_core.settings import get_project_settings, get_studio_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from ayon_core.tools.common_models import (
ProjectsModel,
HierarchyModel,
UsersModel,
)
from .abstract import (
AbstractLauncherFrontEnd,
@ -30,13 +34,12 @@ class BaseLauncherController(
self._addons_manager = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._actions_model = ActionsModel(self)
self._workfiles_model = WorkfilesModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -209,6 +212,7 @@ class BaseLauncherController(
self._projects_model.reset()
self._hierarchy_model.reset()
self._users_model.reset()
self._actions_model.refresh()
self._projects_model.refresh()
@ -229,8 +233,10 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str):
username = self._get_my_username()
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
@ -238,10 +244,5 @@ class BaseLauncherController(
project_name, assignees
)
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -1,22 +1,12 @@
import time
import uuid
import collections
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.lib import Logger
from ayon_core.lib.attribute_definitions import (
UILabelDef,
EnumDef,
TextDef,
BoolDef,
NumberDef,
HiddenDef,
)
from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs
from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
get_qt_icon,
)
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.launcher.abstract import WebactionContext
@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget):
float - 'label', 'value', 'placeholder', 'min', 'max'
"""
attr_defs = []
for config_field in config_fields:
field_type = config_field["type"]
attr_def = None
if field_type == "label":
label = config_field.get("value")
if label is None:
label = config_field.get("text")
attr_def = UILabelDef(
label, key=uuid.uuid4().hex
)
elif field_type == "boolean":
value = config_field["value"]
if isinstance(value, str):
value = value.lower() == "true"
attr_def = BoolDef(
config_field["name"],
default=value,
label=config_field.get("label"),
)
elif field_type == "text":
attr_def = TextDef(
config_field["name"],
default=config_field.get("value"),
label=config_field.get("label"),
placeholder=config_field.get("placeholder"),
multiline=config_field.get("multiline", False),
regex=config_field.get("regex"),
# syntax=config_field["syntax"],
)
elif field_type in ("integer", "float"):
value = config_field.get("value")
if isinstance(value, str):
if field_type == "integer":
value = int(value)
else:
value = float(value)
attr_def = NumberDef(
config_field["name"],
default=value,
label=config_field.get("label"),
decimals=0 if field_type == "integer" else 5,
# placeholder=config_field.get("placeholder"),
minimum=config_field.get("min"),
maximum=config_field.get("max"),
)
elif field_type in ("select", "multiselect"):
attr_def = EnumDef(
config_field["name"],
items=config_field["options"],
default=config_field.get("value"),
label=config_field.get("label"),
multiselection=field_type == "multiselect",
)
elif field_type == "hidden":
attr_def = HiddenDef(
config_field["name"],
default=config_field.get("value"),
)
if attr_def is None:
print(f"Unknown config field type: {field_type}")
attr_def = UILabelDef(
f"Unknown field type '{field_type}",
key=uuid.uuid4().hex
)
attr_defs.append(attr_def)
attr_defs = webaction_fields_to_attribute_defs(config_fields)
dialog = AttributeDefinitionsDialog(
attr_defs,

View file

@ -2,19 +2,47 @@ import qtawesome
from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget
from .workfiles_page import WorkfilesPage
class LauncherFoldersWidget(FoldersWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._folders_view.installEventFilter(self)
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class LauncherTasksWidget(TasksWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._tasks_view.installEventFilter(self)
def deselect(self):
sel_model = self._tasks_view.selectionModel()
sel_model.clearSelection()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super().__init__(parent)
@ -46,34 +74,15 @@ class HierarchyPage(QtWidgets.QWidget):
content_body.setOrientation(QtCore.Qt.Horizontal)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(filters_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
filters_layout = QtWidgets.QHBoxLayout(filters_widget)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.addWidget(folders_filter_text, 1)
filters_layout.addWidget(my_tasks_label, 0)
filters_layout.addWidget(my_tasks_checkbox, 0)
filters_widget = FoldersFiltersWidget(self)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget = LauncherFoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
folders_widget.set_deselectable(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
tasks_widget = LauncherTasksWidget(controller, content_body)
# - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body)
@ -93,17 +102,19 @@ class HierarchyPage(QtWidgets.QWidget):
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect(
filters_widget.text_changed.connect(self._on_filter_text_changed)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
folders_widget.focused_in.connect(self._on_folders_focus)
tasks_widget.focused_in.connect(self._on_tasks_focus)
self._is_visible = False
self._controller = controller
self._filters_widget = filters_widget
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page
@ -126,8 +137,9 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
self._filters_widget.is_my_tasks_checked()
)
def _on_back_clicked(self):
@ -139,15 +151,21 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, state):
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
def _on_folders_focus(self):
self._workfiles_page.deselect()
def _on_tasks_focus(self):
self._workfiles_page.deselect()

View file

@ -3,7 +3,7 @@ from typing import Optional
import ayon_api
from qtpy import QtCore, QtWidgets, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils import get_qt_icon, DeselectableTreeView
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1
@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel):
return icon
class WorkfilesView(QtWidgets.QTreeView):
class WorkfilesView(DeselectableTreeView):
def drawBranches(self, painter, rect, index):
return
@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget):
def refresh(self) -> None:
self._workfiles_model.refresh()
def deselect(self):
sel_model = self._workfiles_view.selectionModel()
sel_model.clearSelection()
def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)

View file

@ -316,43 +316,34 @@ class ActionItem:
Args:
identifier (str): Action identifier.
label (str): Action label.
icon (dict[str, Any]): Action icon definition.
tooltip (str): Action tooltip.
group_label (Optional[str]): Group label.
icon (Optional[dict[str, Any]]): Action icon definition.
tooltip (Optional[str]): Action tooltip.
order (int): Action order.
data (Optional[dict[str, Any]]): Additional action data.
options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]):
Action options. Note: 'qargparse' is considered as deprecated.
order (int): Action order.
project_name (str): Project name.
folder_ids (list[str]): Folder ids.
product_ids (list[str]): Product ids.
version_ids (list[str]): Version ids.
representation_ids (list[str]): Representation ids.
"""
"""
def __init__(
self,
identifier,
label,
icon,
tooltip,
options,
order,
project_name,
folder_ids,
product_ids,
version_ids,
representation_ids,
identifier: str,
label: str,
group_label: Optional[str],
icon: Optional[dict[str, Any]],
tooltip: Optional[str],
order: int,
data: Optional[dict[str, Any]],
options: Optional[list],
):
self.identifier = identifier
self.label = label
self.group_label = group_label
self.icon = icon
self.tooltip = tooltip
self.options = options
self.data = data
self.order = order
self.project_name = project_name
self.folder_ids = folder_ids
self.product_ids = product_ids
self.version_ids = version_ids
self.representation_ids = representation_ids
self.options = options
def _options_to_data(self):
options = self.options
@ -364,30 +355,26 @@ class ActionItem:
# future development of detached UI tools it would be better to be
# prepared for it.
raise NotImplementedError(
"{}.to_data is not implemented. Use Attribute definitions"
" from 'ayon_core.lib' instead of 'qargparse'.".format(
self.__class__.__name__
)
f"{self.__class__.__name__}.to_data is not implemented."
" Use Attribute definitions from 'ayon_core.lib'"
" instead of 'qargparse'."
)
def to_data(self):
def to_data(self) -> dict[str, Any]:
options = self._options_to_data()
return {
"identifier": self.identifier,
"label": self.label,
"group_label": self.group_label,
"icon": self.icon,
"tooltip": self.tooltip,
"options": options,
"order": self.order,
"project_name": self.project_name,
"folder_ids": self.folder_ids,
"product_ids": self.product_ids,
"version_ids": self.version_ids,
"representation_ids": self.representation_ids,
"data": self.data,
"options": options,
}
@classmethod
def from_data(cls, data):
def from_data(cls, data) -> "ActionItem":
options = data["options"]
if options:
options = deserialize_attr_defs(options)
@ -666,6 +653,21 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
@abstractmethod
def get_available_tags_by_entity_type(
self, project_name: str
@ -990,43 +992,35 @@ class FrontendLoaderController(_BaseLoaderController):
# Load action items
@abstractmethod
def get_versions_action_items(self, project_name, version_ids):
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
"""Action items for versions selection.
Args:
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
entity_ids (set[str]): Entity ids.
entity_type (str): Entity type.
Returns:
list[ActionItem]: List of action items.
"""
pass
@abstractmethod
def get_representations_action_items(
self, project_name, representation_ids
):
"""Action items for representations selection.
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
Returns:
list[ActionItem]: List of action items.
"""
pass
@abstractmethod
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
"""Trigger action item.
@ -1044,13 +1038,15 @@ class FrontendLoaderController(_BaseLoaderController):
}
Args:
identifier (str): Action identifier.
options (dict[str, Any]): Action option values from UI.
identifier (sttr): Plugin identifier.
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
representation_ids (Iterable[str]): Representation ids.
"""
selected_ids (set[str]): Selected entity ids.
selected_entity_type (str): Selected entity type.
data (Optional[dict[str, Any]]): Additional action item data.
options (dict[str, Any]): Action option values from UI.
form_values (dict[str, Any]): Action form values from UI.
"""
pass
@abstractmethod

View file

@ -2,13 +2,17 @@ from __future__ import annotations
import logging
import uuid
from typing import Optional
from typing import Optional, Any
import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import get_current_host_name
from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles
from ayon_core.lib import (
NestedCacheItem,
CacheItem,
filter_profiles,
)
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.pipeline import Anatomy, get_current_context
from ayon_core.host import ILoadHost
@ -18,12 +22,14 @@ from ayon_core.tools.common_models import (
ThumbnailsModel,
TagItem,
ProductTypeIconMapping,
UsersModel,
)
from .abstract import (
BackendLoaderController,
FrontendLoaderController,
ProductTypesFilter
ProductTypesFilter,
ActionItem,
)
from .models import (
SelectionModel,
@ -32,6 +38,8 @@ from .models import (
SiteSyncModel
)
NOT_SET = object()
class ExpectedSelection:
def __init__(self, controller):
@ -124,6 +132,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._loader_actions_model = LoaderActionsModel(self)
self._thumbnails_model = ThumbnailsModel()
self._sitesync_model = SiteSyncModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -160,6 +169,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._projects_model.reset()
self._thumbnails_model.reset()
self._sitesync_model.reset()
self._users_model.reset()
self._projects_model.refresh()
@ -235,6 +245,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
output[folder_id] = label
return output
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def get_available_tags_by_entity_type(
self, project_name: str
) -> dict[str, list[str]]:
@ -296,45 +317,47 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
project_name, product_ids, group_name
)
def get_versions_action_items(self, project_name, version_ids):
return self._loader_actions_model.get_versions_action_items(
project_name, version_ids)
def get_representations_action_items(
self, project_name, representation_ids):
action_items = (
self._loader_actions_model.get_representations_action_items(
project_name, representation_ids)
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
action_items = self._loader_actions_model.get_action_items(
project_name, entity_ids, entity_type
)
action_items.extend(self._sitesync_model.get_sitesync_action_items(
project_name, representation_ids)
site_sync_items = self._sitesync_model.get_sitesync_action_items(
project_name, entity_ids, entity_type
)
action_items.extend(site_sync_items)
return action_items
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
if self._sitesync_model.is_sitesync_action(identifier):
self._sitesync_model.trigger_action_item(
identifier,
project_name,
representation_ids
data,
)
return
self._loader_actions_model.trigger_action_item(
identifier,
options,
project_name,
version_ids,
representation_ids
identifier=identifier,
project_name=project_name,
selected_ids=selected_ids,
selected_entity_type=selected_entity_type,
data=data,
options=options,
form_values=form_values,
)
# Selection model wrappers
@ -476,20 +499,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def is_standard_projects_filter_enabled(self):
return self._host is not None
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def get_product_types_filter(self):
output = ProductTypesFilter(
is_allow_list=False,
@ -545,3 +554,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
product_types=profile["filter_product_types"]
)
return output
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()

View file

@ -5,10 +5,16 @@ import traceback
import inspect
import collections
import uuid
from typing import Optional, Callable, Any
import ayon_api
from ayon_core.lib import NestedCacheItem
from ayon_core.lib import NestedCacheItem, Logger
from ayon_core.pipeline.actions import (
LoaderActionsContext,
LoaderActionSelection,
SelectionEntitiesCache,
)
from ayon_core.pipeline.load import (
discover_loader_plugins,
ProductLoaderPlugin,
@ -23,6 +29,7 @@ from ayon_core.pipeline.load import (
from ayon_core.tools.loader.abstract import ActionItem
ACTIONS_MODEL_SENDER = "actions.model"
LOADER_PLUGIN_ID = "__loader_plugin__"
NOT_SET = object()
@ -44,6 +51,7 @@ class LoaderActionsModel:
loaders_cache_lifetime = 30
def __init__(self, controller):
self._log = Logger.get_logger(self.__class__.__name__)
self._controller = controller
self._current_context_project = NOT_SET
self._loaders_by_identifier = NestedCacheItem(
@ -52,6 +60,15 @@ class LoaderActionsModel:
levels=1, lifetime=self.loaders_cache_lifetime)
self._repre_loaders = NestedCacheItem(
levels=1, lifetime=self.loaders_cache_lifetime)
self._loader_actions = LoaderActionsContext()
self._projects_cache = NestedCacheItem(levels=1, lifetime=60)
self._folders_cache = NestedCacheItem(levels=2, lifetime=300)
self._tasks_cache = NestedCacheItem(levels=2, lifetime=300)
self._products_cache = NestedCacheItem(levels=2, lifetime=300)
self._versions_cache = NestedCacheItem(levels=2, lifetime=1200)
self._representations_cache = NestedCacheItem(levels=2, lifetime=1200)
self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200)
def reset(self):
"""Reset the model with all cached items."""
@ -60,64 +77,58 @@ class LoaderActionsModel:
self._loaders_by_identifier.reset()
self._product_loaders.reset()
self._repre_loaders.reset()
self._loader_actions.reset()
def get_versions_action_items(self, project_name, version_ids):
"""Get action items for given version ids.
self._folders_cache.reset()
self._tasks_cache.reset()
self._products_cache.reset()
self._versions_cache.reset()
self._representations_cache.reset()
self._repre_parents_cache.reset()
Args:
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
version_context_by_id = {}
repre_context_by_id = {}
if entity_type == "representation":
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_representations(project_name, entity_ids)
Returns:
list[ActionItem]: List of action items.
"""
if entity_type == "version":
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_versions(project_name, entity_ids)
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_versions(
project_name,
version_ids
)
return self._get_action_items_for_contexts(
action_items = self._get_action_items_for_contexts(
project_name,
version_context_by_id,
repre_context_by_id
)
def get_representations_action_items(
self, project_name, representation_ids
):
"""Get action items for given representation ids.
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
Returns:
list[ActionItem]: List of action items.
"""
(
product_context_by_id,
repre_context_by_id
) = self._contexts_for_representations(
action_items.extend(self._get_loader_action_items(
project_name,
representation_ids
)
return self._get_action_items_for_contexts(
project_name,
product_context_by_id,
repre_context_by_id
)
entity_ids,
entity_type,
version_context_by_id,
repre_context_by_id,
))
return action_items
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
"""Trigger action by identifier.
@ -128,15 +139,21 @@ class LoaderActionsModel:
happened.
Args:
identifier (str): Loader identifier.
options (dict[str, Any]): Loader option values.
identifier (str): Plugin identifier.
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
representation_ids (Iterable[str]): Representation ids.
"""
selected_ids (set[str]): Selected entity ids.
selected_entity_type (str): Selected entity type.
data (Optional[dict[str, Any]]): Additional action item data.
options (dict[str, Any]): Loader option values.
form_values (dict[str, Any]): Form values.
"""
event_data = {
"identifier": identifier,
"project_name": project_name,
"selected_ids": list(selected_ids),
"selected_entity_type": selected_entity_type,
"data": data,
"id": uuid.uuid4().hex,
}
self._controller.emit_event(
@ -144,24 +161,60 @@ class LoaderActionsModel:
event_data,
ACTIONS_MODEL_SENDER,
)
loader = self._get_loader_by_identifier(project_name, identifier)
if representation_ids is not None:
error_info = self._trigger_representation_loader(
loader,
options,
project_name,
representation_ids,
if identifier != LOADER_PLUGIN_ID:
result = None
crashed = False
try:
result = self._loader_actions.execute_action(
identifier=identifier,
selection=LoaderActionSelection(
project_name,
selected_ids,
selected_entity_type,
),
data=data,
form_values=form_values,
)
except Exception:
crashed = True
self._log.warning(
f"Failed to execute action '{identifier}'",
exc_info=True,
)
event_data["result"] = result
event_data["crashed"] = crashed
self._controller.emit_event(
"loader.action.finished",
event_data,
ACTIONS_MODEL_SENDER,
)
elif version_ids is not None:
return
loader = self._get_loader_by_identifier(
project_name, data["loader"]
)
entity_type = data["entity_type"]
entity_ids = data["entity_ids"]
if entity_type == "version":
error_info = self._trigger_version_loader(
loader,
options,
project_name,
version_ids,
entity_ids,
)
elif entity_type == "representation":
error_info = self._trigger_representation_loader(
loader,
options,
project_name,
entity_ids,
)
else:
raise NotImplementedError(
"Invalid arguments to trigger action item")
f"Invalid entity type '{entity_type}' to trigger action item"
)
event_data["error_info"] = error_info
self._controller.emit_event(
@ -276,28 +329,26 @@ class LoaderActionsModel:
self,
loader,
contexts,
project_name,
folder_ids=None,
product_ids=None,
version_ids=None,
representation_ids=None,
entity_ids,
entity_type,
repre_name=None,
):
label = self._get_action_label(loader)
if repre_name:
label = "{} ({})".format(label, repre_name)
label = f"{label} ({repre_name})"
return ActionItem(
get_loader_identifier(loader),
LOADER_PLUGIN_ID,
data={
"entity_ids": entity_ids,
"entity_type": entity_type,
"loader": get_loader_identifier(loader),
},
label=label,
group_label=None,
icon=self._get_action_icon(loader),
tooltip=self._get_action_tooltip(loader),
options=loader.get_options(contexts),
order=loader.order,
project_name=project_name,
folder_ids=folder_ids,
product_ids=product_ids,
version_ids=version_ids,
representation_ids=representation_ids,
options=loader.get_options(contexts),
)
def _get_loaders(self, project_name):
@ -351,15 +402,6 @@ class LoaderActionsModel:
loaders_by_identifier = loaders_by_identifier_c.get_data()
return loaders_by_identifier.get(identifier)
def _actions_sorter(self, action_item):
"""Sort the Loaders by their order and then their name.
Returns:
tuple[int, str]: Sort keys.
"""
return action_item.order, action_item.label
def _contexts_for_versions(self, project_name, version_ids):
"""Get contexts for given version ids.
@ -385,8 +427,8 @@ class LoaderActionsModel:
if not project_name and not version_ids:
return version_context_by_id, repre_context_by_id
version_entities = ayon_api.get_versions(
project_name, version_ids=version_ids
version_entities = self._get_versions(
project_name, version_ids
)
version_entities_by_id = {}
version_entities_by_product_id = collections.defaultdict(list)
@ -397,18 +439,18 @@ class LoaderActionsModel:
version_entities_by_product_id[product_id].append(version_entity)
_product_ids = set(version_entities_by_product_id.keys())
_product_entities = ayon_api.get_products(
project_name, product_ids=_product_ids
_product_entities = self._get_products(
project_name, _product_ids
)
product_entities_by_id = {p["id"]: p for p in _product_entities}
_folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
_folder_entities = ayon_api.get_folders(
project_name, folder_ids=_folder_ids
_folder_entities = self._get_folders(
project_name, _folder_ids
)
folder_entities_by_id = {f["id"]: f for f in _folder_entities}
project_entity = ayon_api.get_project(project_name)
project_entity = self._get_project(project_name)
for version_id, version_entity in version_entities_by_id.items():
product_id = version_entity["productId"]
@ -422,8 +464,15 @@ class LoaderActionsModel:
"version": version_entity,
}
repre_entities = ayon_api.get_representations(
project_name, version_ids=version_ids)
all_repre_ids = set()
for repre_ids in self._get_repre_ids_by_version_ids(
project_name, version_ids
).values():
all_repre_ids |= repre_ids
repre_entities = self._get_representations(
project_name, all_repre_ids
)
for repre_entity in repre_entities:
version_id = repre_entity["versionId"]
version_entity = version_entities_by_id[version_id]
@ -459,49 +508,54 @@ class LoaderActionsModel:
Returns:
tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
representation contexts.
"""
product_context_by_id = {}
"""
version_context_by_id = {}
repre_context_by_id = {}
if not project_name and not repre_ids:
return product_context_by_id, repre_context_by_id
return version_context_by_id, repre_context_by_id
repre_entities = list(ayon_api.get_representations(
project_name, representation_ids=repre_ids
))
repre_entities = self._get_representations(
project_name, repre_ids
)
version_ids = {r["versionId"] for r in repre_entities}
version_entities = ayon_api.get_versions(
project_name, version_ids=version_ids
version_entities = self._get_versions(
project_name, version_ids
)
version_entities_by_id = {
v["id"]: v for v in version_entities
}
product_ids = {v["productId"] for v in version_entities_by_id.values()}
product_entities = ayon_api.get_products(
project_name, product_ids=product_ids
product_entities = self._get_products(
project_name, product_ids
)
product_entities_by_id = {
p["id"]: p for p in product_entities
}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
folder_entities = ayon_api.get_folders(
project_name, folder_ids=folder_ids
folder_entities = self._get_folders(
project_name, folder_ids
)
folder_entities_by_id = {
f["id"]: f for f in folder_entities
}
project_entity = ayon_api.get_project(project_name)
project_entity = self._get_project(project_name)
for product_id, product_entity in product_entities_by_id.items():
version_context_by_id = {}
for version_id, version_entity in version_entities_by_id.items():
product_id = version_entity["productId"]
product_entity = product_entities_by_id[product_id]
folder_id = product_entity["folderId"]
folder_entity = folder_entities_by_id[folder_id]
product_context_by_id[product_id] = {
version_context_by_id[version_id] = {
"project": project_entity,
"folder": folder_entity,
"product": product_entity,
"version": version_entity,
}
for repre_entity in repre_entities:
@ -519,7 +573,125 @@ class LoaderActionsModel:
"version": version_entity,
"representation": repre_entity,
}
return product_context_by_id, repre_context_by_id
return version_context_by_id, repre_context_by_id
def _get_project(self, project_name: str) -> dict[str, Any]:
cache = self._projects_cache[project_name]
if not cache.is_valid:
cache.update_data(ayon_api.get_project(project_name))
return cache.get_data()
def _get_folders(
self, project_name: str, folder_ids: set[str]
) -> list[dict[str, Any]]:
"""Get folders by ids."""
return self._get_entities(
project_name,
folder_ids,
self._folders_cache,
ayon_api.get_folders,
"folder_ids",
)
def _get_products(
self, project_name: str, product_ids: set[str]
) -> list[dict[str, Any]]:
"""Get products by ids."""
return self._get_entities(
project_name,
product_ids,
self._products_cache,
ayon_api.get_products,
"product_ids",
)
def _get_versions(
self, project_name: str, version_ids: set[str]
) -> list[dict[str, Any]]:
"""Get versions by ids."""
return self._get_entities(
project_name,
version_ids,
self._versions_cache,
ayon_api.get_versions,
"version_ids",
)
def _get_representations(
self, project_name: str, representation_ids: set[str]
) -> list[dict[str, Any]]:
"""Get representations by ids."""
return self._get_entities(
project_name,
representation_ids,
self._representations_cache,
ayon_api.get_representations,
"representation_ids",
)
def _get_repre_ids_by_version_ids(
self, project_name: str, version_ids: set[str]
) -> dict[str, set[str]]:
output = {}
if not version_ids:
return output
project_cache = self._repre_parents_cache[project_name]
missing_ids = set()
for version_id in version_ids:
cache = project_cache[version_id]
if cache.is_valid:
output[version_id] = cache.get_data()
else:
missing_ids.add(version_id)
if missing_ids:
repre_cache = self._representations_cache[project_name]
repres_by_parent_id = collections.defaultdict(list)
for repre in ayon_api.get_representations(
project_name, version_ids=missing_ids
):
version_id = repre["versionId"]
repre_cache[repre["id"]].update_data(repre)
repres_by_parent_id[version_id].append(repre)
for version_id, repres in repres_by_parent_id.items():
repre_ids = {
repre["id"]
for repre in repres
}
output[version_id] = set(repre_ids)
project_cache[version_id].update_data(repre_ids)
return output
def _get_entities(
self,
project_name: str,
entity_ids: set[str],
cache: NestedCacheItem,
getter: Callable,
filter_arg: str,
) -> list[dict[str, Any]]:
entities = []
if not entity_ids:
return entities
missing_ids = set()
project_cache = cache[project_name]
for entity_id in entity_ids:
entity_cache = project_cache[entity_id]
if entity_cache.is_valid:
entities.append(entity_cache.get_data())
else:
missing_ids.add(entity_id)
if missing_ids:
for entity in getter(project_name, **{filter_arg: missing_ids}):
entities.append(entity)
entity_id = entity["id"]
project_cache[entity_id].update_data(entity)
return entities
def _get_action_items_for_contexts(
self,
@ -557,51 +729,137 @@ class LoaderActionsModel:
if not filtered_repre_contexts:
continue
repre_ids = set()
repre_version_ids = set()
repre_product_ids = set()
repre_folder_ids = set()
for repre_context in filtered_repre_contexts:
repre_ids.add(repre_context["representation"]["id"])
repre_product_ids.add(repre_context["product"]["id"])
repre_version_ids.add(repre_context["version"]["id"])
repre_folder_ids.add(repre_context["folder"]["id"])
repre_ids = {
repre_context["representation"]["id"]
for repre_context in filtered_repre_contexts
}
item = self._create_loader_action_item(
loader,
repre_contexts,
project_name=project_name,
folder_ids=repre_folder_ids,
product_ids=repre_product_ids,
version_ids=repre_version_ids,
representation_ids=repre_ids,
repre_ids,
"representation",
repre_name=repre_name,
)
action_items.append(item)
# Product Loaders.
version_ids = set(version_context_by_id.keys())
product_folder_ids = set()
product_ids = set()
for product_context in version_context_by_id.values():
product_ids.add(product_context["product"]["id"])
product_folder_ids.add(product_context["folder"]["id"])
version_ids = set(version_context_by_id.keys())
version_contexts = list(version_context_by_id.values())
for loader in product_loaders:
item = self._create_loader_action_item(
loader,
version_contexts,
project_name=project_name,
folder_ids=product_folder_ids,
product_ids=product_ids,
version_ids=version_ids,
version_ids,
"version",
)
action_items.append(item)
action_items.sort(key=self._actions_sorter)
return action_items
def _get_loader_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
version_context_by_id: dict[str, dict[str, Any]],
repre_context_by_id: dict[str, dict[str, Any]],
) -> list[ActionItem]:
"""
Args:
project_name (str): Project name.
entity_ids (set[str]): Selected entity ids.
entity_type (str): Selected entity type.
version_context_by_id (dict[str, dict[str, Any]]): Version context
by id.
repre_context_by_id (dict[str, dict[str, Any]]): Representation
context by id.
Returns:
list[ActionItem]: List of action items.
"""
entities_cache = self._prepare_entities_cache(
project_name,
entity_type,
version_context_by_id,
repre_context_by_id,
)
selection = LoaderActionSelection(
project_name,
entity_ids,
entity_type,
entities_cache=entities_cache
)
items = []
for action in self._loader_actions.get_action_items(selection):
items.append(ActionItem(
action.identifier,
label=action.label,
group_label=action.group_label,
icon=action.icon,
tooltip=None, # action.tooltip,
order=action.order,
data=action.data,
options=None, # action.options,
))
return items
def _prepare_entities_cache(
self,
project_name: str,
entity_type: str,
version_context_by_id: dict[str, dict[str, Any]],
repre_context_by_id: dict[str, dict[str, Any]],
):
project_entity = None
folders_by_id = {}
products_by_id = {}
versions_by_id = {}
representations_by_id = {}
for context in version_context_by_id.values():
if project_entity is None:
project_entity = context["project"]
folder_entity = context["folder"]
product_entity = context["product"]
version_entity = context["version"]
folders_by_id[folder_entity["id"]] = folder_entity
products_by_id[product_entity["id"]] = product_entity
versions_by_id[version_entity["id"]] = version_entity
for context in repre_context_by_id.values():
repre_entity = context["representation"]
representations_by_id[repre_entity["id"]] = repre_entity
# Mapping has to be for all child entities which is available for
# representations only if version is selected
representation_ids_by_version_id = {}
if entity_type == "version":
representation_ids_by_version_id = {
version_id: set()
for version_id in versions_by_id
}
for context in repre_context_by_id.values():
repre_entity = context["representation"]
v_id = repre_entity["versionId"]
representation_ids_by_version_id[v_id].add(repre_entity["id"])
return SelectionEntitiesCache(
project_name,
project_entity=project_entity,
folders_by_id=folders_by_id,
products_by_id=products_by_id,
versions_by_id=versions_by_id,
representations_by_id=representations_by_id,
representation_ids_by_version_id=representation_ids_by_version_id,
)
def _trigger_version_loader(
self,
loader,
@ -634,12 +892,12 @@ class LoaderActionsModel:
project_name, version_ids=version_ids
))
product_ids = {v["productId"] for v in version_entities}
product_entities = ayon_api.get_products(
project_name, product_ids=product_ids
product_entities = self._get_products(
project_name, product_ids
)
product_entities_by_id = {p["id"]: p for p in product_entities}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
folder_entities = ayon_api.get_folders(
folder_entities = self._get_folders(
project_name, folder_ids=folder_ids
)
folder_entities_by_id = {f["id"]: f for f in folder_entities}

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Iterable, Optional
import arrow
import ayon_api
from ayon_api.graphql_queries import project_graphql_query
from ayon_api.operations import OperationsSession
from ayon_core.lib import NestedCacheItem
@ -202,7 +203,7 @@ class ProductsModel:
cache = self._product_type_items_cache[project_name]
if not cache.is_valid:
icons_mapping = self._get_product_type_icons(project_name)
product_types = ayon_api.get_project_product_types(project_name)
product_types = self._get_project_product_types(project_name)
cache.update_data([
ProductTypeItem(
product_type["name"],
@ -462,6 +463,24 @@ class ProductsModel:
PRODUCTS_MODEL_SENDER
)
def _get_project_product_types(self, project_name: str) -> list[dict]:
"""This is a temporary solution for product types fetching.
There was a bug in ayon_api.get_project(...) which did not use GraphQl
but REST instead. That is fixed in ayon-python-api 1.2.6 that will
be as part of ayon launcher 1.4.3 release.
"""
if not project_name:
return []
query = project_graphql_query({"productTypes.name"})
query.set_variable_value("projectName", project_name)
parsed_data = query.query(ayon_api.get_server_api_connection())
project = parsed_data["project"]
if project is None:
return []
return project["productTypes"]
def _get_product_type_icons(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import collections
from typing import Any
from ayon_api import (
get_representations,
@ -246,26 +247,32 @@ class SiteSyncModel:
output[repre_id] = repre_cache.get_data()
return output
def get_sitesync_action_items(self, project_name, representation_ids):
def get_sitesync_action_items(
self, project_name, entity_ids, entity_type
):
"""
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
entity_ids (set[str]): Selected entity ids.
entity_type (str): Selected entity type.
Returns:
list[ActionItem]: Actions that can be shown in loader.
"""
if entity_type != "representation":
return []
if not self.is_sitesync_enabled(project_name):
return []
repres_status = self.get_representations_sync_status(
project_name, representation_ids
project_name, entity_ids
)
repre_ids_per_identifier = collections.defaultdict(set)
for repre_id in representation_ids:
for repre_id in entity_ids:
repre_status = repres_status[repre_id]
local_status, remote_status = repre_status
@ -293,36 +300,32 @@ class SiteSyncModel:
return action_items
def is_sitesync_action(self, identifier):
def is_sitesync_action(self, identifier: str) -> bool:
"""Should be `identifier` handled by SiteSync.
Args:
identifier (str): Action identifier.
identifier (str): Plugin identifier.
Returns:
bool: Should action be handled by SiteSync.
"""
return identifier in {
UPLOAD_IDENTIFIER,
DOWNLOAD_IDENTIFIER,
REMOVE_IDENTIFIER,
}
"""
return identifier == "sitesync.loader.action"
def trigger_action_item(
self,
identifier,
project_name,
representation_ids
project_name: str,
data: dict[str, Any],
):
"""Resets status for site_name or remove local files.
Args:
identifier (str): Action identifier.
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
"""
data (dict[str, Any]): Action item data.
"""
representation_ids = data["representation_ids"]
action_identifier = data["action_identifier"]
active_site = self.get_active_site(project_name)
remote_site = self.get_remote_site(project_name)
@ -346,17 +349,17 @@ class SiteSyncModel:
for repre_id in representation_ids:
repre_entity = repre_entities_by_id.get(repre_id)
product_type = product_type_by_repre_id[repre_id]
if identifier == DOWNLOAD_IDENTIFIER:
if action_identifier == DOWNLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, active_site, product_type
)
elif identifier == UPLOAD_IDENTIFIER:
elif action_identifier == UPLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, remote_site, product_type
)
elif identifier == REMOVE_IDENTIFIER:
elif action_identifier == REMOVE_IDENTIFIER:
self._sitesync_addon.remove_site(
project_name,
repre_id,
@ -476,27 +479,27 @@ class SiteSyncModel:
self,
project_name,
representation_ids,
identifier,
action_identifier,
label,
tooltip,
icon_name
):
return ActionItem(
identifier,
label,
"sitesync.loader.action",
label=label,
group_label=None,
icon={
"type": "awesome-font",
"name": icon_name,
"color": "#999999"
},
tooltip=tooltip,
options={},
order=1,
project_name=project_name,
folder_ids=[],
product_ids=[],
version_ids=[],
representation_ids=representation_ids,
data={
"representation_ids": representation_ids,
"action_identifier": action_identifier,
},
options=None,
)
def _add_site(self, project_name, repre_entity, site_name, product_type):

View file

@ -1,6 +1,7 @@
import uuid
from typing import Optional, Any
from qtpy import QtWidgets, QtGui
from qtpy import QtWidgets, QtGui, QtCore
import qtawesome
from ayon_core.lib.attribute_definitions import AbstractAttrDef
@ -11,9 +12,29 @@ from ayon_core.tools.utils.widgets import (
OptionDialog,
)
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.loader.abstract import ActionItem
def show_actions_menu(action_items, global_point, one_item_selected, parent):
def _actions_sorter(item: tuple[ActionItem, str, str]):
"""Sort the Loaders by their order and then their name.
Returns:
tuple[int, str]: Sort keys.
"""
action_item, group_label, label = item
if group_label is None:
group_label = label
label = ""
return action_item.order, group_label, label
def show_actions_menu(
action_items: list[ActionItem],
global_point: QtCore.QPoint,
one_item_selected: bool,
parent: QtWidgets.QWidget,
) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]:
selected_action_item = None
selected_options = None
@ -26,8 +47,16 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
menu = OptionalMenu(parent)
action_items_by_id = {}
action_items_with_labels = []
for action_item in action_items:
action_items_with_labels.append(
(action_item, action_item.group_label, action_item.label)
)
group_menu_by_label = {}
action_items_by_id = {}
for item in sorted(action_items_with_labels, key=_actions_sorter):
action_item, _, _ = item
item_id = uuid.uuid4().hex
action_items_by_id[item_id] = action_item
item_options = action_item.options
@ -50,7 +79,18 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
action.setData(item_id)
menu.addAction(action)
group_label = action_item.group_label
if group_label:
group_menu = group_menu_by_label.get(group_label)
if group_menu is None:
group_menu = OptionalMenu(group_label, menu)
if icon is not None:
group_menu.setIcon(icon)
menu.addMenu(group_menu)
group_menu_by_label[group_label] = group_menu
group_menu.addAction(action)
else:
menu.addAction(action)
action = menu.exec_(global_point)
if action is not None:

View file

@ -1,11 +1,11 @@
from typing import Optional
import qtpy
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from ayon_core.style import get_objected_colors
from ayon_core.tools.utils import DeselectableTreeView
from ayon_core.tools.utils.folders_widget import FoldersProxyModel
from ayon_core.tools.utils import (
FoldersQtModel,
@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.ExtendedSelection)
folders_model = LoaderFoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
if name:
self._folders_view.expandAll()
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
folder_ids (list[str]): The list of folder ids.
"""
self._folders_proxy_model.set_folder_ids_filter(folder_ids)
def set_merged_products_selection(self, items):
"""

View file

@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget):
if version_id is not None:
version_ids.add(version_id)
action_items = self._controller.get_versions_action_items(
project_name, version_ids)
action_items = self._controller.get_action_items(
project_name, version_ids, "version"
)
# Prepare global point where to show the menu
global_point = self._products_view.mapToGlobal(point)
@ -437,11 +438,13 @@ class ProductsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
action_item.identifier,
options,
action_item.project_name,
version_ids=action_item.version_ids,
representation_ids=action_item.representation_ids,
identifier=action_item.identifier,
project_name=project_name,
selected_ids=version_ids,
selected_entity_type="version",
data=action_item.data,
options=options,
form_values={},
)
def _on_selection_change(self):

View file

@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget):
def _on_context_menu(self, point):
repre_ids = self._get_selected_repre_ids()
action_items = self._controller.get_representations_action_items(
self._selected_project_name, repre_ids
action_items = self._controller.get_action_items(
self._selected_project_name, repre_ids, "representation"
)
global_point = self._repre_view.mapToGlobal(point)
result = show_actions_menu(
@ -399,9 +399,11 @@ class RepresentationsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
action_item.identifier,
options,
action_item.project_name,
version_ids=action_item.version_ids,
representation_ids=action_item.representation_ids,
identifier=action_item.identifier,
project_name=self._selected_project_name,
selected_ids=repre_ids,
selected_entity_type="representation",
data=action_item.data,
options=options,
form_values={},
)

View file

@ -1,11 +1,11 @@
import collections
import hashlib
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import (
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
TasksProxyModel,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel):
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
class LoaderTasksProxyModel(TasksProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
return True
return super().lessThan(left, right)
def filterAcceptsRow(self, row, parent_index):
source_index = self.sourceModel().index(row, 0, parent_index)
if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().filterAcceptsRow(row, parent_index)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget):
if name:
self._tasks_view.expandAll()
def set_task_ids_filter(self, task_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
task_ids (list[str]): The list of folder ids.
"""
self._tasks_proxy_model.set_task_ids_filter(task_ids)
def refresh(self):
self._tasks_model.refresh()

View file

@ -1,18 +1,24 @@
from __future__ import annotations
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.style import load_stylesheet
from ayon_core.pipeline.actions import LoaderActionResult
from ayon_core.tools.utils import (
PlaceholderLineEdit,
MessageOverlayObject,
ErrorMessageBox,
ThumbnailPainterWidget,
RefreshButton,
GoToCurrentButton,
ProjectsCombobox,
get_qt_icon,
FoldersFiltersWidget,
)
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.utils.lib import center_window
from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.common_models import StatusItem
from ayon_core.tools.loader.abstract import ProductTypeItem
from ayon_core.tools.loader.control import LoaderController
@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget):
if controller is None:
controller = LoaderController()
overlay_object = MessageOverlayObject(self)
main_splitter = QtWidgets.QSplitter(self)
context_splitter = QtWidgets.QSplitter(main_splitter)
@ -170,15 +178,14 @@ class LoaderWindow(QtWidgets.QWidget):
context_top_layout.addWidget(go_to_current_btn, 0)
context_top_layout.addWidget(refresh_btn, 0)
folders_filter_input = PlaceholderLineEdit(context_widget)
folders_filter_input.setPlaceholderText("Folder name filter...")
filters_widget = FoldersFiltersWidget(context_widget)
folders_widget = LoaderFoldersWidget(controller, context_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 0)
context_layout.addWidget(folders_filter_input, 0)
context_layout.addWidget(filters_widget, 0)
context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
@ -247,9 +254,12 @@ class LoaderWindow(QtWidgets.QWidget):
projects_combobox.refreshed.connect(self._on_projects_refresh)
folders_widget.refreshed.connect(self._on_folders_refresh)
products_widget.refreshed.connect(self._on_products_refresh)
folders_filter_input.textChanged.connect(
filters_widget.text_changed.connect(
self._on_folder_filter_change
)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
search_bar.filter_changed.connect(self._on_filter_change)
product_group_checkbox.stateChanged.connect(
self._on_product_group_change
@ -294,6 +304,12 @@ class LoaderWindow(QtWidgets.QWidget):
"controller.reset.finished",
self._on_controller_reset_finish,
)
controller.register_event_callback(
"loader.action.finished",
self._on_loader_action_finished,
)
self._overlay_object = overlay_object
self._group_dialog = ProductGroupDialog(controller, self)
@ -303,7 +319,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._refresh_btn = refresh_btn
self._projects_combobox = projects_combobox
self._folders_filter_input = folders_filter_input
self._filters_widget = filters_widget
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -406,6 +422,20 @@ class LoaderWindow(QtWidgets.QWidget):
if self._reset_on_show:
self.refresh()
def _show_toast_message(
self,
message: str,
success: bool = True,
message_id: Optional[str] = None,
):
message_type = None
if not success:
message_type = "error"
self._overlay_object.add_message(
message, message_type, message_id=message_id
)
def _show_group_dialog(self):
project_name = self._projects_combobox.get_selected_project_name()
if not project_name:
@ -421,9 +451,21 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.set_product_ids(project_name, product_ids)
self._group_dialog.show()
def _on_folder_filter_change(self, text):
def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._selected_project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
def _on_product_group_change(self):
self._products_widget.set_enable_grouping(
self._product_group_checkbox.isChecked()
@ -485,6 +527,10 @@ class LoaderWindow(QtWidgets.QWidget):
if not self._refresh_handler.project_refreshed:
self._projects_combobox.refresh()
self._update_filters()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_load_finished(self, event):
error_info = event["error_info"]
@ -494,6 +540,77 @@ class LoaderWindow(QtWidgets.QWidget):
box = LoadErrorMessageBox(error_info, self)
box.show()
def _on_loader_action_finished(self, event):
crashed = event["crashed"]
if crashed:
self._show_toast_message(
"Action failed",
success=False,
)
return
result: Optional[LoaderActionResult] = event["result"]
if result is None:
return
if result.message:
self._show_toast_message(
result.message, result.success
)
if result.form is None:
return
form = result.form
dialog = AttributeDefinitionsDialog(
form.fields,
title=form.title,
parent=self,
)
if result.form_values:
dialog.set_values(result.form_values)
submit_label = form.submit_label
submit_icon = form.submit_icon
cancel_label = form.cancel_label
cancel_icon = form.cancel_icon
if submit_icon:
submit_icon = get_qt_icon(submit_icon)
if cancel_icon:
cancel_icon = get_qt_icon(cancel_icon)
if submit_label:
dialog.set_submit_label(submit_label)
else:
dialog.set_submit_visible(False)
if submit_icon:
dialog.set_submit_icon(submit_icon)
if cancel_label:
dialog.set_cancel_label(cancel_label)
else:
dialog.set_cancel_visible(False)
if cancel_icon:
dialog.set_cancel_icon(cancel_icon)
dialog.setMinimumSize(300, 140)
result = dialog.exec_()
if result != QtWidgets.QDialog.Accepted:
return
form_values = dialog.get_values()
self._controller.trigger_action_item(
identifier=event["identifier"],
project_name=event["project_name"],
selected_ids=event["selected_ids"],
selected_entity_type=event["selected_entity_type"],
options={},
data=event["data"],
form_values=form_values,
)
def _on_project_selection_changed(self, event):
self._selected_project_name = event["project_name"]
self._update_filters()

View file

@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
"""Get folder id from folder path."""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
# --- Create ---
@abstractmethod
def get_creator_items(self) -> Dict[str, "CreatorItem"]:

View file

@ -11,7 +11,11 @@ from ayon_core.pipeline import (
registered_host,
get_process_id,
)
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from ayon_core.tools.common_models import (
ProjectsModel,
HierarchyModel,
UsersModel,
)
from .models import (
PublishModel,
@ -101,6 +105,7 @@ class PublisherController(
# Cacher of avalon documents
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -317,6 +322,17 @@ class PublisherController(
return False
return True
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
# --- Publish specific callbacks ---
def get_context_title(self):
"""Get context title for artist shown at the top of main window."""
@ -359,6 +375,7 @@ class PublisherController(
self._emit_event("controller.reset.started")
self._hierarchy_model.reset()
self._users_model.reset()
# Publish part must be reset after plugins
self._create_model.reset()

View file

@ -1,5 +1,6 @@
import logging
import re
import copy
from typing import (
Union,
List,
@ -34,6 +35,7 @@ from ayon_core.pipeline.create import (
ConvertorsOperationFailed,
ConvertorItem,
)
from ayon_core.tools.publisher.abstract import (
AbstractPublisherBackend,
CardMessageTypes,
@ -1098,7 +1100,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
creator_attributes[key] = value
creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value

View file

@ -21,6 +21,7 @@ from ayon_core.pipeline.plugin_discover import DiscoverResult
from ayon_core.pipeline.publish import (
get_publish_instance_label,
PublishError,
filter_crashed_publish_paths,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherBackend
@ -107,11 +108,14 @@ class PublishReportMaker:
creator_discover_result: Optional[DiscoverResult] = None,
convertor_discover_result: Optional[DiscoverResult] = None,
publish_discover_result: Optional[DiscoverResult] = None,
blocking_crashed_paths: Optional[list[str]] = None,
):
self._create_discover_result: Union[DiscoverResult, None] = None
self._convert_discover_result: Union[DiscoverResult, None] = None
self._publish_discover_result: Union[DiscoverResult, None] = None
self._blocking_crashed_paths: list[str] = []
self._all_instances_by_id: Dict[str, pyblish.api.Instance] = {}
self._plugin_data_by_id: Dict[str, Any] = {}
self._current_plugin_id: Optional[str] = None
@ -120,6 +124,7 @@ class PublishReportMaker:
creator_discover_result,
convertor_discover_result,
publish_discover_result,
blocking_crashed_paths,
)
def reset(
@ -127,12 +132,14 @@ class PublishReportMaker:
creator_discover_result: Union[DiscoverResult, None],
convertor_discover_result: Union[DiscoverResult, None],
publish_discover_result: Union[DiscoverResult, None],
blocking_crashed_paths: list[str],
):
"""Reset report and clear all data."""
self._create_discover_result = creator_discover_result
self._convert_discover_result = convertor_discover_result
self._publish_discover_result = publish_discover_result
self._blocking_crashed_paths = blocking_crashed_paths
self._all_instances_by_id = {}
self._plugin_data_by_id = {}
@ -242,9 +249,10 @@ class PublishReportMaker:
"instances": instances_details,
"context": self._extract_context_data(publish_context),
"crashed_file_paths": crashed_file_paths,
"blocking_crashed_paths": list(self._blocking_crashed_paths),
"id": uuid.uuid4().hex,
"created_at": now.isoformat(),
"report_version": "1.1.0",
"report_version": "1.1.1",
}
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
@ -959,11 +967,16 @@ class PublishModel:
self._publish_plugins_proxy = PublishPluginsProxy(
publish_plugins
)
blocking_crashed_paths = filter_crashed_publish_paths(
create_context.get_current_project_name(),
set(create_context.publish_discover_result.crashed_file_paths),
project_settings=create_context.get_current_project_settings(),
)
self._publish_report.reset(
create_context.creator_discover_result,
create_context.convertor_discover_result,
create_context.publish_discover_result,
blocking_crashed_paths,
)
for plugin in create_context.publish_plugins_mismatch_targets:
self._publish_report.set_plugin_skipped(plugin.id)

View file

@ -139,3 +139,6 @@ class PublishReport:
self.logs = logs
self.crashed_plugin_paths = report_data["crashed_file_paths"]
self.blocking_crashed_paths = report_data.get(
"blocking_crashed_paths", []
)

View file

@ -7,6 +7,7 @@ from ayon_core.tools.utils import (
SeparatorWidget,
IconButton,
paint_image_with_color,
get_qt_icon,
)
from ayon_core.resources import get_image_path
from ayon_core.style import get_objected_colors
@ -46,10 +47,13 @@ def get_pretty_milliseconds(value):
class PluginLoadReportModel(QtGui.QStandardItemModel):
_blocking_icon = None
def __init__(self):
super().__init__()
self._traceback_by_filepath = {}
self._items_by_filepath = {}
self._blocking_crashed_paths = set()
self._is_active = True
self._need_refresh = False
@ -75,6 +79,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
for filepath in to_remove:
self._traceback_by_filepath.pop(filepath)
self._blocking_crashed_paths = set(report.blocking_crashed_paths)
self._update_items()
def _update_items(self):
@ -83,6 +88,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
parent = self.invisibleRootItem()
if not self._traceback_by_filepath:
parent.removeRows(0, parent.rowCount())
self._items_by_filepath = {}
return
new_items = []
@ -91,12 +97,18 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
set(self._items_by_filepath) - set(self._traceback_by_filepath)
)
for filepath in self._traceback_by_filepath:
if filepath in self._items_by_filepath:
continue
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
self._items_by_filepath[filepath] = item
item = self._items_by_filepath.get(filepath)
if item is None:
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
self._items_by_filepath[filepath] = item
icon = None
if filepath.replace("\\", "/") in self._blocking_crashed_paths:
icon = self._get_blocking_icon()
item.setData(icon, QtCore.Qt.DecorationRole)
if new_items:
parent.appendRows(new_items)
@ -113,6 +125,16 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
item = self._items_by_filepath.pop(filepath)
parent.removeRow(item.row())
@classmethod
def _get_blocking_icon(cls):
if cls._blocking_icon is None:
cls._blocking_icon = get_qt_icon({
"type": "material-symbols",
"name": "block",
"color": "red",
})
return cls._blocking_icon
class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
@ -856,7 +878,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
report = PublishReport(report_data)
self.set_report(report)
def set_report(self, report):
def set_report(self, report: PublishReport) -> None:
self._ignore_selection_changes = True
self._report_item = report
@ -866,6 +888,10 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report)
self._plugins_details_widget.set_report(report)
if report.blocking_crashed_paths:
self._details_tab_widget.setCurrentWidget(
self._plugin_load_report_widget
)
self._ignore_selection_changes = False

View file

@ -202,7 +202,7 @@ class ContextCardWidget(CardWidget):
Is not visually under group widget and is always at the top of card view.
"""
def __init__(self, parent):
def __init__(self, parent: QtWidgets.QWidget):
super().__init__(parent)
self._id = CONTEXT_ID
@ -211,7 +211,12 @@ class ContextCardWidget(CardWidget):
icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("ProductTypeIconLabel")
label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
label_widget = QtWidgets.QLabel(f"<span>{CONTEXT_LABEL}</span>", self)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
label_widget.setTextInteractionFlags(
QtCore.Qt.NoTextInteraction
)
icon_layout = QtWidgets.QHBoxLayout()
icon_layout.setContentsMargins(5, 5, 5, 5)
@ -288,6 +293,8 @@ class InstanceCardWidget(CardWidget):
self._last_product_name = None
self._last_variant = None
self._last_label = None
self._last_folder_path = None
self._last_task_name = None
icon_widget = IconValuePixmapLabel(group_icon, self)
icon_widget.setObjectName("ProductTypeIconLabel")
@ -383,29 +390,54 @@ class InstanceCardWidget(CardWidget):
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
@staticmethod
def _get_card_widget_sub_label(
folder_path: Optional[str],
task_name: Optional[str],
) -> str:
sublabel = ""
if folder_path:
folder_name = folder_path.rsplit("/", 1)[-1]
sublabel = f"<b>{folder_name}</b>"
if task_name:
sublabel += f" - <i>{task_name}</i>"
return sublabel
def _update_product_name(self):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
folder_path = self.instance.folder_path
task_name = self.instance.task_name
if (
variant == self._last_variant
and product_name == self._last_product_name
and label == self._last_label
and folder_path == self._last_folder_path
and task_name == self._last_task_name
):
return
self._last_variant = variant
self._last_product_name = product_name
self._last_label = label
self._last_folder_path = folder_path
self._last_task_name = task_name
# Make `variant` bold
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:
replacement = "<b>{}</b>".format(part)
replacement = f"<b>{part}</b>"
label = label.replace(part, replacement)
label = f"<span>{label}</span>"
sublabel = self._get_card_widget_sub_label(folder_path, task_name)
if sublabel:
label += f"<br/><span style=\"font-size: 8pt;\">{sublabel}</span>"
self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
@ -702,11 +734,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh(self):
"""Refresh instances in view based on CreatedContext."""
self._make_sure_context_widget_exists()
self._update_convertors_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
@ -814,6 +844,8 @@ class InstanceCardView(AbstractInstanceView):
widget.setVisible(False)
widget.deleteLater()
sorted_group_names.insert(0, CONTEXT_GROUP)
self._parent_id_by_id = parent_id_by_id
self._instance_ids_by_parent_id = instance_ids_by_parent_id
self._group_name_by_instance_id = group_by_instance_id
@ -881,7 +913,7 @@ class InstanceCardView(AbstractInstanceView):
context_info,
is_parent_active,
group_icon,
group_widget
group_widget,
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)

View file

@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton
from ayon_core.tools.common_models import HierarchyExpectedSelection
from ayon_core.tools.utils import FoldersWidget, TasksWidget
from ayon_core.tools.utils import (
FoldersWidget,
TasksWidget,
FoldersFiltersWidget,
GoToCurrentButton,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_widget = QtWidgets.QWidget(self)
folder_filter_input = PlaceholderLineEdit(headers_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(headers_widget)
current_context_btn = GoToCurrentButton(headers_widget)
current_context_btn.setToolTip("Go to current context")
@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0)
headers_layout.addWidget(folder_filter_input, 1)
headers_layout.setSpacing(5)
headers_layout.addWidget(filters_widget, 1)
headers_layout.addWidget(current_context_btn, 0)
hierarchy_controller = CreateHierarchyController(controller)
@ -207,15 +211,17 @@ class CreateContextWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(headers_widget, 0)
main_layout.addSpacing(5)
main_layout.addWidget(folders_widget, 2)
main_layout.addWidget(tasks_widget, 1)
folders_widget.selection_changed.connect(self._on_folder_change)
tasks_widget.selection_changed.connect(self._on_task_change)
current_context_btn.clicked.connect(self._on_current_context_click)
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
self._folder_filter_input = folder_filter_input
self._filters_widget = filters_widget
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -285,6 +291,10 @@ class CreateContextWidget(QtWidgets.QWidget):
self._hierarchy_controller.set_expected_selection(
self._last_project_name, folder_id, task_name
)
# Update my tasks
self._on_my_tasks_change(
self._filters_widget.is_my_tasks_checked()
)
def _clear_selection(self):
self._folders_widget.set_selected_folder(None)
@ -303,5 +313,17 @@ class CreateContextWidget(QtWidgets.QWidget):
self._last_project_name, folder_id, task_name
)
def _on_folder_filter_change(self, text):
def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
def _on_my_tasks_change(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._last_project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -310,9 +310,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = None
if self._context_change_is_enabled():
folder_path = self._context_widget.get_selected_folder_path()
if folder_path is None:
folder_path = self.get_current_folder_path()
return folder_path or None
def _get_folder_id(self):
@ -328,9 +325,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = self._context_widget.get_selected_folder_path()
if folder_path:
task_name = self._context_widget.get_selected_task_name()
if not task_name:
task_name = self.get_current_task_name()
return task_name
def _set_context_enabled(self, enabled):
@ -710,11 +704,13 @@ class CreateWidget(QtWidgets.QWidget):
def _on_first_show(self):
width = self.width()
part = int(width / 4)
rem_width = width - part
self._main_splitter_widget.setSizes([part, rem_width])
rem_width = rem_width - part
self._creators_splitter.setSizes([part, rem_width])
part = int(width / 9)
context_width = part * 3
create_sel_width = part * 2
rem_width = width - context_width
self._main_splitter_widget.setSizes([context_width, rem_width])
rem_width -= create_sel_width
self._creators_splitter.setSizes([create_sel_width, rem_width])
def showEvent(self, event):
super().showEvent(event)

View file

@ -1,7 +1,10 @@
from qtpy import QtWidgets
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.tools.utils import PlaceholderLineEdit, FoldersWidget
from ayon_core.tools.utils import (
FoldersWidget,
FoldersFiltersWidget,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog):
super().__init__(parent)
self.setWindowTitle("Select folder")
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(self)
folders_controller = FoldersDialogController(controller)
folders_widget = FoldersWidget(folders_controller, self)
@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.setSpacing(5)
layout.addWidget(filters_widget, 0)
layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0)
@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog):
)
folders_widget.double_clicked.connect(self._on_ok_clicked)
filter_input.textChanged.connect(self._on_filter_change)
filters_widget.text_changed.connect(self._on_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._controller = controller
self._filter_input = filter_input
self._filters_widget = filters_widget
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
@ -88,6 +92,50 @@ class FoldersDialog(QtWidgets.QDialog):
self._first_show = True
self._default_height = 500
self._project_name = None
def showEvent(self, event):
"""Refresh folders widget on show."""
super().showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset widget."""
if not force and not self._soft_reset_enabled:
return
self._project_name = self._controller.get_current_project_name()
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._folders_widget.set_project_name(self._project_name)
self._on_my_tasks_change(self._filters_widget.is_my_tasks_checked())
def get_selected_folder_path(self):
"""Get selected folder path."""
return self._selected_folder_path
def set_selected_folders(self, folder_paths: list[str]) -> None:
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._filters_widget.set_text("")
self._filters_widget.set_my_tasks_checked(False)
folder_id = None
for folder_path in folder_paths:
folder_id = self._controller.get_folder_id_from_path(folder_path)
if folder_id:
break
if folder_id:
self._folders_widget.set_selected_folder(folder_id)
def _on_first_show(self):
center = self.rect().center()
size = self.size()
@ -103,27 +151,6 @@ class FoldersDialog(QtWidgets.QDialog):
# Change reset enabled so model is reset on show event
self._soft_reset_enabled = True
def showEvent(self, event):
"""Refresh folders widget on show."""
super().showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset widget."""
if not force and not self._soft_reset_enabled:
return
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._folders_widget.set_project_name(
self._controller.get_current_project_name()
)
def _on_filter_change(self, text):
"""Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text)
@ -137,22 +164,11 @@ class FoldersDialog(QtWidgets.QDialog):
)
self.done(1)
def set_selected_folders(self, folder_paths):
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._filter_input.setText("")
folder_id = None
for folder_path in folder_paths:
folder_id = self._controller.get_folder_id_from_path(folder_path)
if folder_id:
break
if folder_id:
self._folders_widget.set_selected_folder(folder_id)
def get_selected_folder_path(self):
"""Get selected folder path."""
return self._selected_folder_path
def _on_my_tasks_change(self, enabled: bool) -> None:
folder_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import os
import json
import time
import collections
import copy
from typing import Optional
from typing import Optional, Any
from qtpy import QtWidgets, QtCore, QtGui
@ -393,6 +395,9 @@ class PublisherWindow(QtWidgets.QDialog):
self._publish_frame_visible = None
self._tab_on_reset = None
self._create_context_valid: bool = True
self._blocked_by_crashed_paths: bool = False
self._error_messages_to_show = collections.deque()
self._errors_dialog_message_timer = errors_dialog_message_timer
@ -406,6 +411,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._show_counter = 0
self._window_is_visible = False
self._update_footer_state()
@property
def controller(self) -> AbstractPublisherFrontend:
"""Kept for compatibility with traypublisher."""
@ -664,11 +671,33 @@ class PublisherWindow(QtWidgets.QDialog):
self._tab_on_reset = tab
def _update_publish_details_widget(self, force=False):
if not force and not self._is_on_details_tab():
def set_current_tab(self, tab):
if tab == "create":
self._go_to_create_tab()
elif tab == "publish":
self._go_to_publish_tab()
elif tab == "report":
self._go_to_report_tab()
elif tab == "details":
self._go_to_details_tab()
if not self._window_is_visible:
self.set_tab_on_reset(tab)
def _update_publish_details_widget(
self,
force: bool = False,
report_data: Optional[dict[str, Any]] = None,
) -> None:
if (
report_data is None
and not force
and not self._is_on_details_tab()
):
return
report_data = self._controller.get_publish_report()
if report_data is None:
report_data = self._controller.get_publish_report()
self._publish_details_widget.set_report_data(report_data)
def _on_help_click(self):
@ -678,13 +707,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._help_dialog.show()
window = self.window()
if hasattr(QtWidgets.QApplication, "desktop"):
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
screen = window.screen()
screen_geo = screen.geometry()
window_geo = window.geometry()
dialog_x = window_geo.x() + window_geo.width()
@ -757,19 +781,6 @@ class PublisherWindow(QtWidgets.QDialog):
def _set_current_tab(self, identifier):
self._tabs_widget.set_current_tab(identifier)
def set_current_tab(self, tab):
if tab == "create":
self._go_to_create_tab()
elif tab == "publish":
self._go_to_publish_tab()
elif tab == "report":
self._go_to_report_tab()
elif tab == "details":
self._go_to_details_tab()
if not self._window_is_visible:
self.set_tab_on_reset(tab)
def _is_current_tab(self, identifier):
return self._tabs_widget.is_current_tab(identifier)
@ -870,26 +881,56 @@ class PublisherWindow(QtWidgets.QDialog):
# Reset style
self._comment_input.setStyleSheet("")
def _set_footer_enabled(self, enabled):
self._save_btn.setEnabled(True)
def _set_create_context_valid(self, valid: bool) -> None:
self._create_context_valid = valid
self._update_footer_state()
def _set_blocked(self, blocked: bool) -> None:
self._blocked_by_crashed_paths = blocked
self._overview_widget.setEnabled(not blocked)
self._update_footer_state()
if not blocked:
return
self.set_tab_on_reset("details")
self._go_to_details_tab()
QtWidgets.QMessageBox.critical(
self,
"Failed to load plugins",
(
"Failed to load plugins that do prevent you from"
" using publish tool.\n"
"Please contact your TD or administrator."
)
)
def _update_footer_state(self) -> None:
enabled = (
not self._blocked_by_crashed_paths
and self._create_context_valid
)
save_enabled = not self._blocked_by_crashed_paths
self._save_btn.setEnabled(save_enabled)
self._reset_btn.setEnabled(True)
if enabled:
self._stop_btn.setEnabled(False)
self._validate_btn.setEnabled(True)
self._publish_btn.setEnabled(True)
else:
self._stop_btn.setEnabled(enabled)
self._validate_btn.setEnabled(enabled)
self._publish_btn.setEnabled(enabled)
self._stop_btn.setEnabled(False)
self._validate_btn.setEnabled(enabled)
self._publish_btn.setEnabled(enabled)
def _on_publish_reset(self):
self._create_tab.setEnabled(True)
self._set_comment_input_visiblity(True)
self._set_publish_overlay_visibility(False)
self._set_publish_visibility(False)
self._update_publish_details_widget()
report_data = self._controller.get_publish_report()
blocked = bool(report_data["blocking_crashed_paths"])
self._set_blocked(blocked)
self._update_publish_details_widget(report_data=report_data)
def _on_controller_reset(self):
self._update_publish_details_widget(force=True)
self._first_reset, first_reset = False, self._first_reset
if self._tab_on_reset is not None:
self._tab_on_reset, new_tab = None, self._tab_on_reset
@ -957,7 +998,7 @@ class PublisherWindow(QtWidgets.QDialog):
def _validate_create_instances(self):
if not self._controller.is_host_valid():
self._set_footer_enabled(True)
self._set_create_context_valid(True)
return
active_instances_by_id = {
@ -978,7 +1019,7 @@ class PublisherWindow(QtWidgets.QDialog):
if all_valid is None:
all_valid = True
self._set_footer_enabled(bool(all_valid))
self._set_create_context_valid(bool(all_valid))
def _on_create_model_reset(self):
self._validate_create_instances()

View file

@ -41,6 +41,7 @@ class PushToContextController:
self._process_item_id = None
self._use_original_name = False
self._version_up = False
self.set_source(project_name, version_ids)
@ -212,7 +213,7 @@ class PushToContextController:
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1,
version_up=self._version_up,
use_original_name=self._use_original_name,
)
item_ids.append(item_id)
@ -229,6 +230,9 @@ class PushToContextController:
thread.start()
return item_ids
def set_version_up(self, state):
self._version_up = state
def wait_for_process_thread(self):
if self._process_thread is None:
return

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