mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'develop' into enhancement/1297-product-base-types-creation-and-creator-plugins
This commit is contained in:
commit
3e77031d9c
25 changed files with 1873 additions and 714 deletions
|
|
@ -62,6 +62,7 @@ from .execute import (
|
|||
run_subprocess,
|
||||
run_detached_process,
|
||||
run_ayon_launcher_process,
|
||||
run_detached_ayon_launcher_process,
|
||||
path_to_subprocess_arg,
|
||||
CREATE_NO_WINDOW
|
||||
)
|
||||
|
|
@ -131,6 +132,7 @@ from .ayon_info import (
|
|||
is_staging_enabled,
|
||||
is_dev_mode_enabled,
|
||||
is_in_tests,
|
||||
get_settings_variant,
|
||||
)
|
||||
|
||||
terminal = Terminal
|
||||
|
|
@ -160,6 +162,7 @@ __all__ = [
|
|||
"run_subprocess",
|
||||
"run_detached_process",
|
||||
"run_ayon_launcher_process",
|
||||
"run_detached_ayon_launcher_process",
|
||||
"path_to_subprocess_arg",
|
||||
"CREATE_NO_WINDOW",
|
||||
|
||||
|
|
@ -240,4 +243,5 @@ __all__ = [
|
|||
"is_staging_enabled",
|
||||
"is_dev_mode_enabled",
|
||||
"is_in_tests",
|
||||
"get_settings_variant",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -78,15 +78,15 @@ def is_using_ayon_console():
|
|||
return "ayon_console" in executable_filename
|
||||
|
||||
|
||||
def is_headless_mode_enabled():
|
||||
def is_headless_mode_enabled() -> bool:
|
||||
return os.getenv("AYON_HEADLESS_MODE") == "1"
|
||||
|
||||
|
||||
def is_staging_enabled():
|
||||
def is_staging_enabled() -> bool:
|
||||
return os.getenv("AYON_USE_STAGING") == "1"
|
||||
|
||||
|
||||
def is_in_tests():
|
||||
def is_in_tests() -> bool:
|
||||
"""Process is running in automatic tests mode.
|
||||
|
||||
Returns:
|
||||
|
|
@ -96,7 +96,7 @@ def is_in_tests():
|
|||
return os.environ.get("AYON_IN_TESTS") == "1"
|
||||
|
||||
|
||||
def is_dev_mode_enabled():
|
||||
def is_dev_mode_enabled() -> bool:
|
||||
"""Dev mode is enabled in AYON.
|
||||
|
||||
Returns:
|
||||
|
|
@ -106,6 +106,22 @@ def is_dev_mode_enabled():
|
|||
return os.getenv("AYON_USE_DEV") == "1"
|
||||
|
||||
|
||||
def get_settings_variant() -> str:
|
||||
"""Get AYON settings variant.
|
||||
|
||||
Returns:
|
||||
str: Settings variant.
|
||||
|
||||
"""
|
||||
if is_dev_mode_enabled():
|
||||
return os.environ["AYON_BUNDLE_NAME"]
|
||||
|
||||
if is_staging_enabled():
|
||||
return "staging"
|
||||
|
||||
return "production"
|
||||
|
||||
|
||||
def get_ayon_info():
|
||||
executable_args = get_ayon_launcher_args()
|
||||
if is_running_from_build():
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
|
@ -201,29 +202,9 @@ def clean_envs_for_ayon_process(env=None):
|
|||
return env
|
||||
|
||||
|
||||
def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
|
||||
"""Execute AYON process with passed arguments and wait.
|
||||
|
||||
Wrapper for 'run_process' which prepends AYON executable arguments
|
||||
before passed arguments and define environments if are not passed.
|
||||
|
||||
Values from 'os.environ' are used for environments if are not passed.
|
||||
They are cleaned using 'clean_envs_for_ayon_process' function.
|
||||
|
||||
Example:
|
||||
```
|
||||
run_ayon_process("run", "<path to .py script>")
|
||||
```
|
||||
|
||||
Args:
|
||||
*args (str): ayon-launcher cli arguments.
|
||||
**kwargs (Any): Keyword arguments for subprocess.Popen.
|
||||
|
||||
Returns:
|
||||
str: Full output of subprocess concatenated stdout and stderr.
|
||||
|
||||
"""
|
||||
args = get_ayon_launcher_args(*args)
|
||||
def _prepare_ayon_launcher_env(
|
||||
add_sys_paths: bool, kwargs: dict
|
||||
) -> dict[str, str]:
|
||||
env = kwargs.pop("env", None)
|
||||
# Keep env untouched if are passed and not empty
|
||||
if not env:
|
||||
|
|
@ -239,8 +220,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
|
|||
new_pythonpath.append(path)
|
||||
lookup_set.add(path)
|
||||
env["PYTHONPATH"] = os.pathsep.join(new_pythonpath)
|
||||
|
||||
return run_subprocess(args, env=env, **kwargs)
|
||||
return env
|
||||
|
||||
|
||||
def run_detached_process(args, **kwargs):
|
||||
|
|
@ -314,6 +294,67 @@ def run_detached_process(args, **kwargs):
|
|||
return process
|
||||
|
||||
|
||||
def run_ayon_launcher_process(
|
||||
*args, add_sys_paths: bool = False, **kwargs
|
||||
) -> str:
|
||||
"""Execute AYON process with passed arguments and wait.
|
||||
|
||||
Wrapper for 'run_process' which prepends AYON executable arguments
|
||||
before passed arguments and define environments if are not passed.
|
||||
|
||||
Values from 'os.environ' are used for environments if are not passed.
|
||||
They are cleaned using 'clean_envs_for_ayon_process' function.
|
||||
|
||||
Example:
|
||||
```
|
||||
run_ayon_launcher_process("run", "<path to .py script>")
|
||||
```
|
||||
|
||||
Args:
|
||||
*args (str): ayon-launcher cli arguments.
|
||||
add_sys_paths (bool): Add system paths to PYTHONPATH.
|
||||
**kwargs (Any): Keyword arguments for subprocess.Popen.
|
||||
|
||||
Returns:
|
||||
str: Full output of subprocess concatenated stdout and stderr.
|
||||
|
||||
"""
|
||||
args = get_ayon_launcher_args(*args)
|
||||
env = _prepare_ayon_launcher_env(add_sys_paths, kwargs)
|
||||
return run_subprocess(args, env=env, **kwargs)
|
||||
|
||||
|
||||
def run_detached_ayon_launcher_process(
|
||||
*args, add_sys_paths: bool = False, **kwargs
|
||||
) -> subprocess.Popen:
|
||||
"""Execute AYON process with passed arguments and wait.
|
||||
|
||||
Wrapper for 'run_process' which prepends AYON executable arguments
|
||||
before passed arguments and define environments if are not passed.
|
||||
|
||||
Values from 'os.environ' are used for environments if are not passed.
|
||||
They are cleaned using 'clean_envs_for_ayon_process' function.
|
||||
|
||||
Example:
|
||||
```
|
||||
run_detached_ayon_launcher_process("run", "<path to .py script>")
|
||||
```
|
||||
|
||||
Args:
|
||||
*args (str): ayon-launcher cli arguments.
|
||||
add_sys_paths (bool): Add system paths to PYTHONPATH.
|
||||
**kwargs (Any): Keyword arguments for subprocess.Popen.
|
||||
|
||||
Returns:
|
||||
subprocess.Popen: Pointer to launched process but it is possible that
|
||||
launched process is already killed (on linux).
|
||||
|
||||
"""
|
||||
args = get_ayon_launcher_args(*args)
|
||||
env = _prepare_ayon_launcher_env(add_sys_paths, kwargs)
|
||||
return run_detached_process(args, env=env, **kwargs)
|
||||
|
||||
|
||||
def path_to_subprocess_arg(path):
|
||||
"""Prepare path for subprocess arguments.
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ from .context_tools import (
|
|||
get_current_task_name
|
||||
)
|
||||
|
||||
from .compatibility import (
|
||||
is_product_base_type_supported,
|
||||
)
|
||||
|
||||
from .workfile import (
|
||||
discover_workfile_build_plugins,
|
||||
register_workfile_build_plugin,
|
||||
|
|
@ -223,4 +227,7 @@ __all__ = (
|
|||
# Backwards compatible function names
|
||||
"install",
|
||||
"uninstall",
|
||||
|
||||
# Feature detection
|
||||
"is_product_base_type_supported",
|
||||
)
|
||||
|
|
|
|||
16
client/ayon_core/pipeline/compatibility.py
Normal file
16
client/ayon_core/pipeline/compatibility.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Package to handle compatibility checks for pipeline components."""
|
||||
|
||||
|
||||
def is_product_base_type_supported() -> bool:
|
||||
"""Check support for product base types.
|
||||
|
||||
This function checks if the current pipeline supports product base types.
|
||||
Once this feature is implemented, it will return True. This should be used
|
||||
in places where some kind of backward compatibility is needed to avoid
|
||||
breaking existing functionality that relies on the current behavior.
|
||||
|
||||
Returns:
|
||||
bool: True if product base types are supported, False otherwise.
|
||||
|
||||
"""
|
||||
return False
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
|
|
@ -5,7 +6,8 @@ import json
|
|||
import shutil
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass, field
|
||||
import tempfile
|
||||
|
||||
import clique
|
||||
|
|
@ -35,6 +37,39 @@ from ayon_core.pipeline.publish import (
|
|||
from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup
|
||||
|
||||
|
||||
@dataclass
|
||||
class TempData:
|
||||
"""Temporary data used across extractor's process."""
|
||||
fps: float
|
||||
frame_start: int
|
||||
frame_end: int
|
||||
handle_start: int
|
||||
handle_end: int
|
||||
frame_start_handle: int
|
||||
frame_end_handle: int
|
||||
output_frame_start: int
|
||||
output_frame_end: int
|
||||
pixel_aspect: float
|
||||
resolution_width: int
|
||||
resolution_height: int
|
||||
origin_repre: dict[str, Any]
|
||||
input_is_sequence: bool
|
||||
first_sequence_frame: int
|
||||
input_allow_bg: bool
|
||||
with_audio: bool
|
||||
without_handles: bool
|
||||
handles_are_set: bool
|
||||
input_ext: str
|
||||
explicit_input_paths: list[str]
|
||||
paths_to_remove: list[str]
|
||||
|
||||
# Set later
|
||||
full_output_path: str = ""
|
||||
filled_files: dict[int, str] = field(default_factory=dict)
|
||||
output_ext_is_image: bool = True
|
||||
output_is_sequence: bool = True
|
||||
|
||||
|
||||
def frame_to_timecode(frame: int, fps: float) -> str:
|
||||
"""Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF).
|
||||
|
||||
|
|
@ -405,10 +440,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
|
||||
temp_data = self.prepare_temp_data(instance, repre, output_def)
|
||||
new_frame_files = {}
|
||||
if temp_data["input_is_sequence"]:
|
||||
if temp_data.input_is_sequence:
|
||||
self.log.debug("Checking sequence to fill gaps in sequence..")
|
||||
|
||||
files = temp_data["origin_repre"]["files"]
|
||||
files = temp_data.origin_repre["files"]
|
||||
collections = clique.assemble(
|
||||
files,
|
||||
)[0]
|
||||
|
|
@ -423,18 +458,18 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
new_frame_files = self.fill_sequence_gaps_from_existing(
|
||||
collection=collection,
|
||||
staging_dir=new_repre["stagingDir"],
|
||||
start_frame=temp_data["frame_start"],
|
||||
end_frame=temp_data["frame_end"],
|
||||
start_frame=temp_data.frame_start,
|
||||
end_frame=temp_data.frame_end,
|
||||
)
|
||||
elif fill_missing_frames == "blank":
|
||||
new_frame_files = self.fill_sequence_gaps_with_blanks(
|
||||
collection=collection,
|
||||
staging_dir=new_repre["stagingDir"],
|
||||
start_frame=temp_data["frame_start"],
|
||||
end_frame=temp_data["frame_end"],
|
||||
resolution_width=temp_data["resolution_width"],
|
||||
resolution_height=temp_data["resolution_height"],
|
||||
extension=temp_data["input_ext"],
|
||||
start_frame=temp_data.frame_start,
|
||||
end_frame=temp_data.frame_end,
|
||||
resolution_width=temp_data.resolution_width,
|
||||
resolution_height=temp_data.resolution_height,
|
||||
extension=temp_data.input_ext,
|
||||
temp_data=temp_data
|
||||
)
|
||||
elif fill_missing_frames == "previous_version":
|
||||
|
|
@ -443,8 +478,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
staging_dir=new_repre["stagingDir"],
|
||||
instance=instance,
|
||||
current_repre_name=repre["name"],
|
||||
start_frame=temp_data["frame_start"],
|
||||
end_frame=temp_data["frame_end"],
|
||||
start_frame=temp_data.frame_start,
|
||||
end_frame=temp_data.frame_end,
|
||||
)
|
||||
# fallback to original workflow
|
||||
if new_frame_files is None:
|
||||
|
|
@ -452,11 +487,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
self.fill_sequence_gaps_from_existing(
|
||||
collection=collection,
|
||||
staging_dir=new_repre["stagingDir"],
|
||||
start_frame=temp_data["frame_start"],
|
||||
end_frame=temp_data["frame_end"],
|
||||
start_frame=temp_data.frame_start,
|
||||
end_frame=temp_data.frame_end,
|
||||
))
|
||||
elif fill_missing_frames == "only_rendered":
|
||||
temp_data["explicit_input_paths"] = [
|
||||
temp_data.explicit_input_paths = [
|
||||
os.path.join(
|
||||
new_repre["stagingDir"], file
|
||||
).replace("\\", "/")
|
||||
|
|
@ -467,10 +502,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# modify range for burnins
|
||||
instance.data["frameStart"] = frame_start
|
||||
instance.data["frameEnd"] = frame_end
|
||||
temp_data["frame_start"] = frame_start
|
||||
temp_data["frame_end"] = frame_end
|
||||
temp_data.frame_start = frame_start
|
||||
temp_data.frame_end = frame_end
|
||||
|
||||
temp_data["filled_files"] = new_frame_files
|
||||
temp_data.filled_files = new_frame_files
|
||||
|
||||
# create or update outputName
|
||||
output_name = new_repre.get("outputName", "")
|
||||
|
|
@ -478,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
if output_name:
|
||||
output_name += "_"
|
||||
output_name += output_def["filename_suffix"]
|
||||
if temp_data["without_handles"]:
|
||||
if temp_data.without_handles:
|
||||
output_name += "_noHandles"
|
||||
|
||||
# add outputName to anatomy format fill_data
|
||||
|
|
@ -491,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# like Resolve or Premiere can detect the start frame for e.g.
|
||||
# review output files
|
||||
"timecode": frame_to_timecode(
|
||||
frame=temp_data["frame_start_handle"],
|
||||
frame=temp_data.frame_start_handle,
|
||||
fps=float(instance.data["fps"])
|
||||
)
|
||||
})
|
||||
|
|
@ -508,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
except ZeroDivisionError:
|
||||
# TODO recalculate width and height using OIIO before
|
||||
# conversion
|
||||
if 'exr' in temp_data["origin_repre"]["ext"]:
|
||||
if 'exr' in temp_data.origin_repre["ext"]:
|
||||
self.log.warning(
|
||||
(
|
||||
"Unsupported compression on input files."
|
||||
|
|
@ -531,16 +566,16 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
for filepath in new_frame_files.values():
|
||||
os.unlink(filepath)
|
||||
|
||||
for filepath in temp_data["paths_to_remove"]:
|
||||
for filepath in temp_data.paths_to_remove:
|
||||
os.unlink(filepath)
|
||||
|
||||
new_repre.update({
|
||||
"fps": temp_data["fps"],
|
||||
"fps": temp_data.fps,
|
||||
"name": "{}_{}".format(output_name, output_ext),
|
||||
"outputName": output_name,
|
||||
"outputDef": output_def,
|
||||
"frameStartFtrack": temp_data["output_frame_start"],
|
||||
"frameEndFtrack": temp_data["output_frame_end"],
|
||||
"frameStartFtrack": temp_data.output_frame_start,
|
||||
"frameEndFtrack": temp_data.output_frame_end,
|
||||
"ffmpeg_cmd": subprcs_cmd
|
||||
})
|
||||
|
||||
|
|
@ -566,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# - there can be more than one collection
|
||||
return isinstance(repre["files"], (list, tuple))
|
||||
|
||||
def prepare_temp_data(self, instance, repre, output_def):
|
||||
def prepare_temp_data(self, instance, repre, output_def) -> TempData:
|
||||
"""Prepare dictionary with values used across extractor's process.
|
||||
|
||||
All data are collected from instance, context, origin representation
|
||||
|
|
@ -582,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
output_def (dict): Definition of output of this plugin.
|
||||
|
||||
Returns:
|
||||
dict: All data which are used across methods during process.
|
||||
TempData: All data which are used across methods during process.
|
||||
Their values should not change during process but new keys
|
||||
with values may be added.
|
||||
"""
|
||||
|
|
@ -647,30 +682,30 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
else:
|
||||
ext = os.path.splitext(repre["files"])[1].replace(".", "")
|
||||
|
||||
return {
|
||||
"fps": float(instance.data["fps"]),
|
||||
"frame_start": frame_start,
|
||||
"frame_end": frame_end,
|
||||
"handle_start": handle_start,
|
||||
"handle_end": handle_end,
|
||||
"frame_start_handle": frame_start_handle,
|
||||
"frame_end_handle": frame_end_handle,
|
||||
"output_frame_start": int(output_frame_start),
|
||||
"output_frame_end": int(output_frame_end),
|
||||
"pixel_aspect": instance.data.get("pixelAspect", 1),
|
||||
"resolution_width": instance.data.get("resolutionWidth"),
|
||||
"resolution_height": instance.data.get("resolutionHeight"),
|
||||
"origin_repre": repre,
|
||||
"input_is_sequence": input_is_sequence,
|
||||
"first_sequence_frame": first_sequence_frame,
|
||||
"input_allow_bg": input_allow_bg,
|
||||
"with_audio": with_audio,
|
||||
"without_handles": without_handles,
|
||||
"handles_are_set": handles_are_set,
|
||||
"input_ext": ext,
|
||||
"explicit_input_paths": [], # absolute paths to rendered files
|
||||
"paths_to_remove": []
|
||||
}
|
||||
return TempData(
|
||||
fps=float(instance.data["fps"]),
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
handle_start=handle_start,
|
||||
handle_end=handle_end,
|
||||
frame_start_handle=frame_start_handle,
|
||||
frame_end_handle=frame_end_handle,
|
||||
output_frame_start=int(output_frame_start),
|
||||
output_frame_end=int(output_frame_end),
|
||||
pixel_aspect=instance.data.get("pixelAspect", 1),
|
||||
resolution_width=instance.data.get("resolutionWidth"),
|
||||
resolution_height=instance.data.get("resolutionHeight"),
|
||||
origin_repre=repre,
|
||||
input_is_sequence=input_is_sequence,
|
||||
first_sequence_frame=first_sequence_frame,
|
||||
input_allow_bg=input_allow_bg,
|
||||
with_audio=with_audio,
|
||||
without_handles=without_handles,
|
||||
handles_are_set=handles_are_set,
|
||||
input_ext=ext,
|
||||
explicit_input_paths=[], # absolute paths to rendered files
|
||||
paths_to_remove=[]
|
||||
)
|
||||
|
||||
def _ffmpeg_arguments(
|
||||
self,
|
||||
|
|
@ -691,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
instance (Instance): Currently processed instance.
|
||||
new_repre (dict): Representation representing output of this
|
||||
process.
|
||||
temp_data (dict): Base data for successful process.
|
||||
temp_data (TempData): Base data for successful process.
|
||||
"""
|
||||
|
||||
# Get FFmpeg arguments from profile presets
|
||||
|
|
@ -733,32 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
|
||||
# Set output frames len to 1 when output is single image
|
||||
if (
|
||||
temp_data["output_ext_is_image"]
|
||||
and not temp_data["output_is_sequence"]
|
||||
temp_data.output_ext_is_image
|
||||
and not temp_data.output_is_sequence
|
||||
):
|
||||
output_frames_len = 1
|
||||
|
||||
else:
|
||||
output_frames_len = (
|
||||
temp_data["output_frame_end"]
|
||||
- temp_data["output_frame_start"]
|
||||
temp_data.output_frame_end
|
||||
- temp_data.output_frame_start
|
||||
+ 1
|
||||
)
|
||||
|
||||
duration_seconds = float(output_frames_len / temp_data["fps"])
|
||||
duration_seconds = float(output_frames_len / temp_data.fps)
|
||||
|
||||
# Define which layer should be used
|
||||
if layer_name:
|
||||
ffmpeg_input_args.extend(["-layer", layer_name])
|
||||
|
||||
explicit_input_paths = temp_data["explicit_input_paths"]
|
||||
if temp_data["input_is_sequence"] and not explicit_input_paths:
|
||||
explicit_input_paths = temp_data.explicit_input_paths
|
||||
if temp_data.input_is_sequence and not explicit_input_paths:
|
||||
# Set start frame of input sequence (just frame in filename)
|
||||
# - definition of input filepath
|
||||
# - add handle start if output should be without handles
|
||||
start_number = temp_data["first_sequence_frame"]
|
||||
if temp_data["without_handles"] and temp_data["handles_are_set"]:
|
||||
start_number += temp_data["handle_start"]
|
||||
start_number = temp_data.first_sequence_frame
|
||||
if temp_data.without_handles and temp_data.handles_are_set:
|
||||
start_number += temp_data.handle_start
|
||||
ffmpeg_input_args.extend([
|
||||
"-start_number", str(start_number)
|
||||
])
|
||||
|
|
@ -771,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# }
|
||||
# Add framerate to input when input is sequence
|
||||
ffmpeg_input_args.extend([
|
||||
"-framerate", str(temp_data["fps"])
|
||||
"-framerate", str(temp_data.fps)
|
||||
])
|
||||
# Add duration of an input sequence if output is video
|
||||
if not temp_data["output_is_sequence"]:
|
||||
if not temp_data.output_is_sequence:
|
||||
ffmpeg_input_args.extend([
|
||||
"-to", "{:0.10f}".format(duration_seconds)
|
||||
])
|
||||
|
||||
if temp_data["output_is_sequence"] and not explicit_input_paths:
|
||||
if temp_data.output_is_sequence and not explicit_input_paths:
|
||||
# Set start frame of output sequence (just frame in filename)
|
||||
# - this is definition of an output
|
||||
ffmpeg_output_args.extend([
|
||||
"-start_number", str(temp_data["output_frame_start"])
|
||||
"-start_number", str(temp_data.output_frame_start)
|
||||
])
|
||||
|
||||
# Change output's duration and start point if should not contain
|
||||
# handles
|
||||
if temp_data["without_handles"] and temp_data["handles_are_set"]:
|
||||
if temp_data.without_handles and temp_data.handles_are_set:
|
||||
# Set output duration in seconds
|
||||
ffmpeg_output_args.extend([
|
||||
"-t", "{:0.10}".format(duration_seconds)
|
||||
])
|
||||
|
||||
# Add -ss (start offset in seconds) if input is not sequence
|
||||
if not temp_data["input_is_sequence"]:
|
||||
start_sec = float(temp_data["handle_start"]) / temp_data["fps"]
|
||||
if not temp_data.input_is_sequence:
|
||||
start_sec = float(temp_data.handle_start) / temp_data.fps
|
||||
# Set start time without handles
|
||||
# - Skip if start sec is 0.0
|
||||
if start_sec > 0.0:
|
||||
|
|
@ -805,7 +840,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
])
|
||||
|
||||
# Set frame range of output when input or output is sequence
|
||||
elif temp_data["output_is_sequence"]:
|
||||
elif temp_data.output_is_sequence:
|
||||
ffmpeg_output_args.extend([
|
||||
"-frames:v", str(output_frames_len)
|
||||
])
|
||||
|
|
@ -813,10 +848,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
if not explicit_input_paths:
|
||||
# Add video/image input path
|
||||
ffmpeg_input_args.extend([
|
||||
"-i", path_to_subprocess_arg(temp_data["full_input_path"])
|
||||
"-i", path_to_subprocess_arg(temp_data.full_input_path)
|
||||
])
|
||||
else:
|
||||
frame_duration = 1 / temp_data["fps"]
|
||||
frame_duration = 1 / temp_data.fps
|
||||
|
||||
explicit_frames_meta = tempfile.NamedTemporaryFile(
|
||||
mode="w", prefix="explicit_frames", suffix=".txt", delete=False
|
||||
|
|
@ -826,21 +861,21 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
with open(explicit_frames_path, "w") as fp:
|
||||
lines = [
|
||||
f"file '{path}'{os.linesep}duration {frame_duration}"
|
||||
for path in temp_data["explicit_input_paths"]
|
||||
for path in temp_data.explicit_input_paths
|
||||
]
|
||||
fp.write("\n".join(lines))
|
||||
temp_data["paths_to_remove"].append(explicit_frames_path)
|
||||
temp_data.paths_to_remove.append(explicit_frames_path)
|
||||
|
||||
# let ffmpeg use only rendered files, might have gaps
|
||||
ffmpeg_input_args.extend([
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", path_to_subprocess_arg(explicit_frames_path),
|
||||
"-r", str(temp_data["fps"])
|
||||
"-r", str(temp_data.fps)
|
||||
])
|
||||
|
||||
# Add audio arguments if there are any. Skipped when output are images.
|
||||
if not temp_data["output_ext_is_image"] and temp_data["with_audio"]:
|
||||
if not temp_data.output_ext_is_image and temp_data.with_audio:
|
||||
audio_in_args, audio_filters, audio_out_args = self.audio_args(
|
||||
instance, temp_data, duration_seconds
|
||||
)
|
||||
|
|
@ -862,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
bg_red, bg_green, bg_blue, bg_alpha = bg_color
|
||||
|
||||
if bg_alpha > 0.0:
|
||||
if not temp_data["input_allow_bg"]:
|
||||
if not temp_data.input_allow_bg:
|
||||
self.log.info((
|
||||
"Output definition has defined BG color input was"
|
||||
" resolved as does not support adding BG."
|
||||
|
|
@ -893,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
|
||||
# NOTE This must be latest added item to output arguments.
|
||||
ffmpeg_output_args.append(
|
||||
path_to_subprocess_arg(temp_data["full_output_path"])
|
||||
path_to_subprocess_arg(temp_data.full_output_path)
|
||||
)
|
||||
|
||||
return self.ffmpeg_full_args(
|
||||
|
|
@ -985,7 +1020,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
current_repre_name: str,
|
||||
start_frame: int,
|
||||
end_frame: int
|
||||
) -> Optional[Dict[int, str]]:
|
||||
) -> Optional[dict[int, str]]:
|
||||
"""Tries to replace missing frames from ones from last version"""
|
||||
repre_file_paths = self._get_last_version_files(
|
||||
instance, current_repre_name)
|
||||
|
|
@ -1072,8 +1107,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
resolution_width: int,
|
||||
resolution_height: int,
|
||||
extension: str,
|
||||
temp_data: Dict[str, Any]
|
||||
) -> Optional[Dict[int, str]]:
|
||||
temp_data: TempData
|
||||
) -> Optional[dict[int, str]]:
|
||||
"""Fills missing files by blank frame."""
|
||||
|
||||
blank_frame_path = None
|
||||
|
|
@ -1089,7 +1124,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
blank_frame_path = self._create_blank_frame(
|
||||
staging_dir, extension, resolution_width, resolution_height
|
||||
)
|
||||
temp_data["paths_to_remove"].append(blank_frame_path)
|
||||
temp_data.paths_to_remove.append(blank_frame_path)
|
||||
speedcopy.copyfile(blank_frame_path, hole_fpath)
|
||||
added_files[frame] = hole_fpath
|
||||
|
||||
|
|
@ -1129,7 +1164,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
staging_dir: str,
|
||||
start_frame: int,
|
||||
end_frame: int
|
||||
) -> Dict[int, str]:
|
||||
) -> dict[int, str]:
|
||||
"""Fill missing files in sequence by duplicating existing ones.
|
||||
|
||||
This will take nearest frame file and copy it with so as to fill
|
||||
|
|
@ -1176,7 +1211,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
|
||||
return added_files
|
||||
|
||||
def input_output_paths(self, new_repre, output_def, temp_data):
|
||||
def input_output_paths(self, new_repre, output_def, temp_data: TempData):
|
||||
"""Deduce input nad output file paths based on entered data.
|
||||
|
||||
Input may be sequence of images, video file or single image file and
|
||||
|
|
@ -1189,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
"sequence_file" (if output is sequence) keys to new representation.
|
||||
"""
|
||||
|
||||
repre = temp_data["origin_repre"]
|
||||
repre = temp_data.origin_repre
|
||||
src_staging_dir = repre["stagingDir"]
|
||||
dst_staging_dir = new_repre["stagingDir"]
|
||||
|
||||
if temp_data["input_is_sequence"]:
|
||||
if temp_data.input_is_sequence:
|
||||
collections = clique.assemble(repre["files"])[0]
|
||||
full_input_path = os.path.join(
|
||||
src_staging_dir,
|
||||
|
|
@ -1218,13 +1253,13 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# Make sure to have full path to one input file
|
||||
full_input_path_single_file = full_input_path
|
||||
|
||||
filled_files = temp_data["filled_files"]
|
||||
filled_files = temp_data.filled_files
|
||||
if filled_files:
|
||||
first_frame, first_file = next(iter(filled_files.items()))
|
||||
if first_file < full_input_path_single_file:
|
||||
self.log.warning(f"Using filled frame: '{first_file}'")
|
||||
full_input_path_single_file = first_file
|
||||
temp_data["first_sequence_frame"] = first_frame
|
||||
temp_data.first_sequence_frame = first_frame
|
||||
|
||||
filename_suffix = output_def["filename_suffix"]
|
||||
|
||||
|
|
@ -1252,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
)
|
||||
if output_is_sequence:
|
||||
new_repre_files = []
|
||||
frame_start = temp_data["output_frame_start"]
|
||||
frame_end = temp_data["output_frame_end"]
|
||||
frame_start = temp_data.output_frame_start
|
||||
frame_end = temp_data.output_frame_end
|
||||
|
||||
filename_base = "{}_{}".format(filename, filename_suffix)
|
||||
# Temporary template for frame filling. Example output:
|
||||
|
|
@ -1290,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
new_repre["stagingDir"] = dst_staging_dir
|
||||
|
||||
# Store paths to temp data
|
||||
temp_data["full_input_path"] = full_input_path
|
||||
temp_data["full_input_path_single_file"] = full_input_path_single_file
|
||||
temp_data["full_output_path"] = full_output_path
|
||||
temp_data.full_input_path = full_input_path
|
||||
temp_data.full_input_path_single_file = full_input_path_single_file
|
||||
temp_data.full_output_path = full_output_path
|
||||
|
||||
# Store information about output
|
||||
temp_data["output_ext_is_image"] = output_ext_is_image
|
||||
temp_data["output_is_sequence"] = output_is_sequence
|
||||
temp_data.output_ext_is_image = output_ext_is_image
|
||||
temp_data.output_is_sequence = output_is_sequence
|
||||
|
||||
self.log.debug("Input path {}".format(full_input_path))
|
||||
self.log.debug("Output path {}".format(full_output_path))
|
||||
|
||||
def audio_args(self, instance, temp_data, duration_seconds):
|
||||
def audio_args(self, instance, temp_data: TempData, duration_seconds):
|
||||
"""Prepares FFMpeg arguments for audio inputs."""
|
||||
audio_in_args = []
|
||||
audio_filters = []
|
||||
|
|
@ -1318,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
frame_start_ftrack = instance.data.get("frameStartFtrack")
|
||||
if frame_start_ftrack is not None:
|
||||
offset_frames = frame_start_ftrack - audio["offset"]
|
||||
offset_seconds = offset_frames / temp_data["fps"]
|
||||
offset_seconds = offset_frames / temp_data.fps
|
||||
|
||||
if offset_seconds > 0:
|
||||
audio_in_args.append(
|
||||
|
|
@ -1502,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
|
||||
return output
|
||||
|
||||
def rescaling_filters(self, temp_data, output_def, new_repre):
|
||||
def rescaling_filters(self, temp_data: TempData, output_def, new_repre):
|
||||
"""Prepare vieo filters based on tags in new representation.
|
||||
|
||||
It is possible to add letterboxes to output video or rescale to
|
||||
|
|
@ -1522,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking))
|
||||
|
||||
# NOTE Skipped using instance's resolution
|
||||
full_input_path_single_file = temp_data["full_input_path_single_file"]
|
||||
full_input_path_single_file = temp_data.full_input_path_single_file
|
||||
try:
|
||||
streams = get_ffprobe_streams(
|
||||
full_input_path_single_file, self.log
|
||||
|
|
@ -1547,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
break
|
||||
|
||||
# Get instance data
|
||||
pixel_aspect = temp_data["pixel_aspect"]
|
||||
pixel_aspect = temp_data.pixel_aspect
|
||||
if reformat_in_baking:
|
||||
self.log.debug((
|
||||
"Using resolution from input. It is already "
|
||||
|
|
@ -1642,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# - use instance resolution only if there were not scale changes
|
||||
# that may massivelly affect output 'use_input_res'
|
||||
if not use_input_res and output_width is None or output_height is None:
|
||||
output_width = temp_data["resolution_width"]
|
||||
output_height = temp_data["resolution_height"]
|
||||
output_width = temp_data.resolution_width
|
||||
output_height = temp_data.resolution_height
|
||||
|
||||
# Use source's input resolution instance does not have set it.
|
||||
if output_width is None or output_height is None:
|
||||
|
|
|
|||
|
|
@ -56,14 +56,9 @@ class _AyonSettingsCache:
|
|||
@classmethod
|
||||
def _get_variant(cls):
|
||||
if _AyonSettingsCache.variant is None:
|
||||
from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled
|
||||
|
||||
variant = "production"
|
||||
if is_dev_mode_enabled():
|
||||
variant = cls._get_bundle_name()
|
||||
elif is_staging_enabled():
|
||||
variant = "staging"
|
||||
from ayon_core.lib import get_settings_variant
|
||||
|
||||
variant = get_settings_variant()
|
||||
# Cache variant
|
||||
_AyonSettingsCache.variant = variant
|
||||
|
||||
|
|
|
|||
|
|
@ -829,6 +829,37 @@ HintedLineEditButton {
|
|||
}
|
||||
|
||||
/* Launcher specific stylesheets */
|
||||
ActionsView[mode="icon"] {
|
||||
/* font size can't be set on items */
|
||||
font-size: 9pt;
|
||||
border: 0px;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
ActionsView[mode="icon"]::item {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
border: 0px;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
ActionsView[mode="icon"]::item:hover {
|
||||
color: {color:font-hover};
|
||||
background: #424A57;
|
||||
}
|
||||
|
||||
ActionsView[mode="icon"]::icon {}
|
||||
|
||||
ActionMenuPopup #Wrapper {
|
||||
border-radius: 0.3em;
|
||||
background: #353B46;
|
||||
}
|
||||
ActionMenuPopup ActionsView[mode="icon"] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#IconView[mode="icon"] {
|
||||
/* font size can't be set on items */
|
||||
font-size: 9pt;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,58 @@
|
|||
from qtpy import QtWidgets
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from qtpy import QtWidgets, QtGui
|
||||
|
||||
from ayon_core.style import load_stylesheet
|
||||
from ayon_core.resources import get_ayon_icon_filepath
|
||||
from ayon_core.lib import AbstractAttrDef
|
||||
|
||||
from .widgets import AttributeDefinitionsWidget
|
||||
|
||||
|
||||
class AttributeDefinitionsDialog(QtWidgets.QDialog):
|
||||
def __init__(self, attr_defs, parent=None):
|
||||
super(AttributeDefinitionsDialog, self).__init__(parent)
|
||||
def __init__(
|
||||
self,
|
||||
attr_defs: list[AbstractAttrDef],
|
||||
title: Optional[str] = None,
|
||||
submit_label: Optional[str] = None,
|
||||
cancel_label: Optional[str] = None,
|
||||
submit_icon: Optional[QtGui.QIcon] = None,
|
||||
cancel_icon: Optional[QtGui.QIcon] = None,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
if title:
|
||||
self.setWindowTitle(title)
|
||||
|
||||
icon = QtGui.QIcon(get_ayon_icon_filepath())
|
||||
self.setWindowIcon(icon)
|
||||
self.setStyleSheet(load_stylesheet())
|
||||
|
||||
attrs_widget = AttributeDefinitionsWidget(attr_defs, self)
|
||||
|
||||
if submit_label is None:
|
||||
submit_label = "OK"
|
||||
|
||||
if cancel_label is None:
|
||||
cancel_label = "Cancel"
|
||||
|
||||
btns_widget = QtWidgets.QWidget(self)
|
||||
ok_btn = QtWidgets.QPushButton("OK", btns_widget)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
|
||||
cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget)
|
||||
submit_btn = QtWidgets.QPushButton(submit_label, btns_widget)
|
||||
|
||||
if submit_icon is not None:
|
||||
submit_btn.setIcon(submit_icon)
|
||||
|
||||
if cancel_icon is not None:
|
||||
cancel_btn.setIcon(cancel_icon)
|
||||
|
||||
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
|
||||
btns_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(ok_btn, 0)
|
||||
btns_layout.addWidget(submit_btn, 0)
|
||||
btns_layout.addWidget(cancel_btn, 0)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
|
|
@ -24,10 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
|
|||
main_layout.addStretch(1)
|
||||
main_layout.addWidget(btns_widget, 0)
|
||||
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
submit_btn.clicked.connect(self.accept)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
self._attrs_widget = attrs_widget
|
||||
self._submit_btn = submit_btn
|
||||
self._cancel_btn = cancel_btn
|
||||
|
||||
def get_values(self):
|
||||
return self._attrs_widget.current_value()
|
||||
|
||||
def set_values(self, values):
|
||||
self._attrs_widget.set_value(values)
|
||||
|
||||
def set_submit_label(self, text: str):
|
||||
self._submit_btn.setText(text)
|
||||
|
||||
def set_submit_icon(self, icon: QtGui.QIcon):
|
||||
self._submit_btn.setIcon(icon)
|
||||
|
||||
def set_submit_visible(self, visible: bool):
|
||||
self._submit_btn.setVisible(visible)
|
||||
|
||||
def set_cancel_label(self, text: str):
|
||||
self._cancel_btn.setText(text)
|
||||
|
||||
def set_cancel_icon(self, icon: QtGui.QIcon):
|
||||
self._cancel_btn.setIcon(icon)
|
||||
|
||||
def set_cancel_visible(self, visible: bool):
|
||||
self._cancel_btn.setVisible(visible)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from ayon_core.tools.utils import (
|
|||
FocusSpinBox,
|
||||
FocusDoubleSpinBox,
|
||||
MultiSelectionComboBox,
|
||||
MarkdownLabel,
|
||||
PlaceholderLineEdit,
|
||||
PlaceholderPlainTextEdit,
|
||||
set_style_property,
|
||||
|
|
@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
|
||||
def set_value(self, value):
|
||||
new_value = copy.deepcopy(value)
|
||||
unused_keys = set(new_value.keys())
|
||||
for widget in self._widgets_by_id.values():
|
||||
attr_def = widget.attr_def
|
||||
if attr_def.key not in new_value:
|
||||
continue
|
||||
unused_keys.remove(attr_def.key)
|
||||
|
||||
widget_value = new_value[attr_def.key]
|
||||
if widget_value is None:
|
||||
|
|
@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
class LabelAttrWidget(_BaseAttrDefWidget):
|
||||
def _ui_init(self):
|
||||
input_widget = QtWidgets.QLabel(self)
|
||||
input_widget = MarkdownLabel(self)
|
||||
label = self.attr_def.label
|
||||
if label:
|
||||
input_widget.setText(str(label))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
from ayon_core.tools.common_models import (
|
||||
ProjectItem,
|
||||
FolderItem,
|
||||
FolderTypeItem,
|
||||
TaskItem,
|
||||
TaskTypeItem,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebactionContext:
|
||||
"""Context used for methods related to webactions."""
|
||||
identifier: str
|
||||
project_name: str
|
||||
folder_id: str
|
||||
task_id: str
|
||||
addon_name: str
|
||||
addon_version: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionItem:
|
||||
"""Item representing single action to trigger.
|
||||
|
||||
Attributes:
|
||||
action_type (Literal["webaction", "local"]): Type of action.
|
||||
identifier (str): Unique identifier of action item.
|
||||
order (int): Action ordering.
|
||||
label (str): Action label.
|
||||
variant_label (Union[str, None]): Variant label, full label is
|
||||
concatenated with space. Actions are grouped under single
|
||||
action if it has same 'label' and have set 'variant_label'.
|
||||
full_label (str): Full label, if not set it is generated
|
||||
from 'label' and 'variant_label'.
|
||||
icon (dict[str, str]): Icon definition.
|
||||
addon_name (Optional[str]): Addon name.
|
||||
addon_version (Optional[str]): Addon version.
|
||||
config_fields (list[dict]): Config fields for webaction.
|
||||
|
||||
"""
|
||||
action_type: str
|
||||
identifier: str
|
||||
order: int
|
||||
label: str
|
||||
variant_label: Optional[str]
|
||||
full_label: str
|
||||
icon: Optional[dict[str, str]]
|
||||
config_fields: list[dict]
|
||||
addon_name: Optional[str] = None
|
||||
addon_version: Optional[str] = None
|
||||
|
||||
|
||||
class AbstractLauncherCommon(ABC):
|
||||
|
|
@ -88,7 +143,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon):
|
|||
class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
||||
# Entity items for UI
|
||||
@abstractmethod
|
||||
def get_project_items(self, sender=None):
|
||||
def get_project_items(
|
||||
self, sender: Optional[str] = None
|
||||
) -> list[ProjectItem]:
|
||||
"""Project items for all projects.
|
||||
|
||||
This function may trigger events 'projects.refresh.started' and
|
||||
|
|
@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_type_items(self, project_name, sender=None):
|
||||
def get_folder_type_items(
|
||||
self, project_name: str, sender: Optional[str] = None
|
||||
) -> list[FolderTypeItem]:
|
||||
"""Folder type items for a project.
|
||||
|
||||
This function may trigger events with topics
|
||||
|
|
@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_task_type_items(self, project_name, sender=None):
|
||||
def get_task_type_items(
|
||||
self, project_name: str, sender: Optional[str] = None
|
||||
) -> list[TaskTypeItem]:
|
||||
"""Task type items for a project.
|
||||
|
||||
This function may trigger events with topics
|
||||
|
|
@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
def get_folder_items(
|
||||
self, project_name: str, sender: Optional[str] = None
|
||||
) -> list[FolderItem]:
|
||||
"""Folder items to visualize project hierarchy.
|
||||
|
||||
This function may trigger events 'folders.refresh.started' and
|
||||
|
|
@ -165,7 +228,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_task_items(self, project_name, folder_id, sender=None):
|
||||
def get_task_items(
|
||||
self, project_name: str, folder_id: str, sender: Optional[str] = None
|
||||
) -> list[TaskItem]:
|
||||
"""Task items.
|
||||
|
||||
This function may trigger events 'tasks.refresh.started' and
|
||||
|
|
@ -185,7 +250,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_project_name(self):
|
||||
def get_selected_project_name(self) -> Optional[str]:
|
||||
"""Selected project name.
|
||||
|
||||
Returns:
|
||||
|
|
@ -195,7 +260,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_folder_id(self):
|
||||
def get_selected_folder_id(self) -> Optional[str]:
|
||||
"""Selected folder id.
|
||||
|
||||
Returns:
|
||||
|
|
@ -205,7 +270,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_task_id(self):
|
||||
def get_selected_task_id(self) -> Optional[str]:
|
||||
"""Selected task id.
|
||||
|
||||
Returns:
|
||||
|
|
@ -215,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_task_name(self):
|
||||
def get_selected_task_name(self) -> Optional[str]:
|
||||
"""Selected task name.
|
||||
|
||||
Returns:
|
||||
|
|
@ -225,7 +290,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_context(self):
|
||||
def get_selected_context(self) -> dict[str, Optional[str]]:
|
||||
"""Get whole selected context.
|
||||
|
||||
Example:
|
||||
|
|
@ -243,7 +308,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_project(self, project_name):
|
||||
def set_selected_project(self, project_name: Optional[str]):
|
||||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
|
|
@ -254,7 +319,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_folder(self, folder_id):
|
||||
def set_selected_folder(self, folder_id: Optional[str]):
|
||||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
|
|
@ -265,7 +330,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_task(self, task_id, task_name):
|
||||
def set_selected_task(
|
||||
self, task_id: Optional[str], task_name: Optional[str]
|
||||
):
|
||||
"""Change selected task.
|
||||
|
||||
Args:
|
||||
|
|
@ -279,7 +346,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
|
||||
# Actions
|
||||
@abstractmethod
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
def get_action_items(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
folder_id: Optional[str],
|
||||
task_id: Optional[str],
|
||||
) -> list[ActionItem]:
|
||||
"""Get action items for given context.
|
||||
|
||||
Args:
|
||||
|
|
@ -295,30 +367,67 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def trigger_action(self, project_name, folder_id, task_id, action_id):
|
||||
def trigger_action(
|
||||
self,
|
||||
action_id: str,
|
||||
project_name: Optional[str],
|
||||
folder_id: Optional[str],
|
||||
task_id: Optional[str],
|
||||
):
|
||||
"""Trigger action on given context.
|
||||
|
||||
Args:
|
||||
action_id (str): Action identifier.
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
action_id (str): Action identifier.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_application_force_not_open_workfile(
|
||||
self, project_name, folder_id, task_id, action_ids, enabled
|
||||
def trigger_webaction(
|
||||
self,
|
||||
context: WebactionContext,
|
||||
action_label: str,
|
||||
form_data: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
"""This is application action related to force not open last workfile.
|
||||
"""Trigger action on the given context.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
action_ids (Iterable[str]): Action identifiers.
|
||||
enabled (bool): New value of force not open workfile.
|
||||
context (WebactionContext): Webaction context.
|
||||
action_label (str): Action label.
|
||||
form_data (Optional[dict[str, Any]]): Form values of action.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_action_config_values(
|
||||
self, context: WebactionContext
|
||||
) -> dict[str, Any]:
|
||||
"""Get action config values.
|
||||
|
||||
Args:
|
||||
context (WebactionContext): Webaction context.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Action config values.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_action_config_values(
|
||||
self,
|
||||
context: WebactionContext,
|
||||
values: dict[str, Any],
|
||||
):
|
||||
"""Set action config values.
|
||||
|
||||
Args:
|
||||
context (WebactionContext): Webaction context.
|
||||
values (dict[str, Any]): Action config values.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -343,14 +452,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_my_tasks_entity_ids(self, project_name: str):
|
||||
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, Union[list[str]]]: Folder and task ids.
|
||||
dict[str, list[str]]: Folder and task ids.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class BaseLauncherController(
|
|||
|
||||
@property
|
||||
def event_system(self):
|
||||
"""Inner event system for workfiles tool controller.
|
||||
"""Inner event system for launcher tool controller.
|
||||
|
||||
Is used for communication with UI. Event system is created on demand.
|
||||
|
||||
|
|
@ -135,16 +135,30 @@ class BaseLauncherController(
|
|||
return self._actions_model.get_action_items(
|
||||
project_name, folder_id, task_id)
|
||||
|
||||
def set_application_force_not_open_workfile(
|
||||
self, project_name, folder_id, task_id, action_ids, enabled
|
||||
def trigger_action(
|
||||
self,
|
||||
identifier,
|
||||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
):
|
||||
self._actions_model.set_application_force_not_open_workfile(
|
||||
project_name, folder_id, task_id, action_ids, enabled
|
||||
self._actions_model.trigger_action(
|
||||
identifier,
|
||||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
)
|
||||
|
||||
def trigger_action(self, project_name, folder_id, task_id, identifier):
|
||||
self._actions_model.trigger_action(
|
||||
project_name, folder_id, task_id, identifier)
|
||||
def trigger_webaction(self, context, action_label, form_data=None):
|
||||
self._actions_model.trigger_webaction(
|
||||
context, action_label, form_data
|
||||
)
|
||||
|
||||
def get_action_config_values(self, context):
|
||||
return self._actions_model.get_action_config_values(context)
|
||||
|
||||
def set_action_config_values(self, context, values):
|
||||
return self._actions_model.set_action_config_values(context, values)
|
||||
|
||||
# General methods
|
||||
def refresh(self):
|
||||
|
|
|
|||
|
|
@ -1,219 +1,47 @@
|
|||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, asdict
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from typing import Any, Optional
|
||||
import webbrowser
|
||||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core import resources
|
||||
from ayon_core.lib import Logger, AYONSettingsRegistry
|
||||
from ayon_core.lib import (
|
||||
Logger,
|
||||
NestedCacheItem,
|
||||
CacheItem,
|
||||
get_settings_variant,
|
||||
run_detached_ayon_launcher_process,
|
||||
)
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.pipeline.actions import (
|
||||
discover_launcher_actions,
|
||||
LauncherAction,
|
||||
LauncherActionSelection,
|
||||
register_launcher_action_path,
|
||||
)
|
||||
from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch
|
||||
|
||||
try:
|
||||
# Available since applications addon 0.2.4
|
||||
from ayon_applications.action import ApplicationAction
|
||||
except ImportError:
|
||||
# Backwards compatibility from 0.3.3 (24/06/10)
|
||||
# TODO: Remove in future releases
|
||||
class ApplicationAction(LauncherAction):
|
||||
"""Action to launch an application.
|
||||
|
||||
Application action based on 'ApplicationManager' system.
|
||||
|
||||
Handling of applications in launcher is not ideal and should be
|
||||
completely redone from scratch. This is just a temporary solution
|
||||
to keep backwards compatibility with AYON launcher.
|
||||
|
||||
Todos:
|
||||
Move handling of errors to frontend.
|
||||
"""
|
||||
|
||||
# Application object
|
||||
application = None
|
||||
# Action attributes
|
||||
name = None
|
||||
label = None
|
||||
label_variant = None
|
||||
group = None
|
||||
icon = None
|
||||
color = None
|
||||
order = 0
|
||||
data = {}
|
||||
project_settings = {}
|
||||
project_entities = {}
|
||||
|
||||
_log = None
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
def is_compatible(self, selection):
|
||||
if not selection.is_task_selected:
|
||||
return False
|
||||
|
||||
project_entity = self.project_entities[selection.project_name]
|
||||
apps = project_entity["attrib"].get("applications")
|
||||
if not apps or self.application.full_name not in apps:
|
||||
return False
|
||||
|
||||
project_settings = self.project_settings[selection.project_name]
|
||||
only_available = project_settings["applications"]["only_available"]
|
||||
if only_available and not self.application.find_executable():
|
||||
return False
|
||||
return True
|
||||
|
||||
def _show_message_box(self, title, message, details=None):
|
||||
from qtpy import QtWidgets, QtGui
|
||||
from ayon_core import style
|
||||
|
||||
dialog = QtWidgets.QMessageBox()
|
||||
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
|
||||
dialog.setWindowIcon(icon)
|
||||
dialog.setStyleSheet(style.load_stylesheet())
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setText(message)
|
||||
if details:
|
||||
dialog.setDetailedText(details)
|
||||
dialog.exec_()
|
||||
|
||||
def process(self, selection, **kwargs):
|
||||
"""Process the full Application action"""
|
||||
|
||||
from ayon_applications import (
|
||||
ApplicationExecutableNotFound,
|
||||
ApplicationLaunchFailed,
|
||||
)
|
||||
|
||||
try:
|
||||
self.application.launch(
|
||||
project_name=selection.project_name,
|
||||
folder_path=selection.folder_path,
|
||||
task_name=selection.task_name,
|
||||
**self.data
|
||||
)
|
||||
|
||||
except ApplicationExecutableNotFound as exc:
|
||||
details = exc.details
|
||||
msg = exc.msg
|
||||
log_msg = str(msg)
|
||||
if details:
|
||||
log_msg += "\n" + details
|
||||
self.log.warning(log_msg)
|
||||
self._show_message_box(
|
||||
"Application executable not found", msg, details
|
||||
)
|
||||
|
||||
except ApplicationLaunchFailed as exc:
|
||||
msg = str(exc)
|
||||
self.log.warning(msg, exc_info=True)
|
||||
self._show_message_box("Application launch failed", msg)
|
||||
from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext
|
||||
|
||||
|
||||
# class Action:
|
||||
# def __init__(self, label, icon=None, identifier=None):
|
||||
# self._label = label
|
||||
# self._icon = icon
|
||||
# self._callbacks = []
|
||||
# self._identifier = identifier or uuid.uuid4().hex
|
||||
# self._checked = True
|
||||
# self._checkable = False
|
||||
#
|
||||
# def set_checked(self, checked):
|
||||
# self._checked = checked
|
||||
#
|
||||
# def set_checkable(self, checkable):
|
||||
# self._checkable = checkable
|
||||
#
|
||||
# def set_label(self, label):
|
||||
# self._label = label
|
||||
#
|
||||
# def add_callback(self, callback):
|
||||
# self._callbacks = callback
|
||||
#
|
||||
#
|
||||
# class Menu:
|
||||
# def __init__(self, label, icon=None):
|
||||
# self.label = label
|
||||
# self.icon = icon
|
||||
# self._actions = []
|
||||
#
|
||||
# def add_action(self, action):
|
||||
# self._actions.append(action)
|
||||
@dataclass
|
||||
class WebactionForm:
|
||||
fields: list[dict[str, Any]]
|
||||
title: str
|
||||
submit_label: str
|
||||
submit_icon: str
|
||||
cancel_label: str
|
||||
cancel_icon: str
|
||||
|
||||
|
||||
class ActionItem:
|
||||
"""Item representing single action to trigger.
|
||||
|
||||
Todos:
|
||||
Get rid of application specific logic.
|
||||
|
||||
Args:
|
||||
identifier (str): Unique identifier of action item.
|
||||
label (str): Action label.
|
||||
variant_label (Union[str, None]): Variant label, full label is
|
||||
concatenated with space. Actions are grouped under single
|
||||
action if it has same 'label' and have set 'variant_label'.
|
||||
icon (dict[str, str]): Icon definition.
|
||||
order (int): Action ordering.
|
||||
is_application (bool): Is action application action.
|
||||
force_not_open_workfile (bool): Force not open workfile. Application
|
||||
related.
|
||||
full_label (Optional[str]): Full label, if not set it is generated
|
||||
from 'label' and 'variant_label'.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier,
|
||||
label,
|
||||
variant_label,
|
||||
icon,
|
||||
order,
|
||||
is_application,
|
||||
force_not_open_workfile,
|
||||
full_label=None
|
||||
):
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
self.variant_label = variant_label
|
||||
self.icon = icon
|
||||
self.order = order
|
||||
self.is_application = is_application
|
||||
self.force_not_open_workfile = force_not_open_workfile
|
||||
self._full_label = full_label
|
||||
|
||||
def copy(self):
|
||||
return self.from_data(self.to_data())
|
||||
|
||||
@property
|
||||
def full_label(self):
|
||||
if self._full_label is None:
|
||||
if self.variant_label:
|
||||
self._full_label = " ".join([self.label, self.variant_label])
|
||||
else:
|
||||
self._full_label = self.label
|
||||
return self._full_label
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"label": self.label,
|
||||
"variant_label": self.variant_label,
|
||||
"icon": self.icon,
|
||||
"order": self.order,
|
||||
"is_application": self.is_application,
|
||||
"force_not_open_workfile": self.force_not_open_workfile,
|
||||
"full_label": self._full_label,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
return cls(**data)
|
||||
@dataclass
|
||||
class WebactionResponse:
|
||||
response_type: str
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
clipboard_text: Optional[str] = None
|
||||
form: Optional[WebactionForm] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
def get_action_icon(action):
|
||||
|
|
@ -264,8 +92,6 @@ class ActionsModel:
|
|||
controller (AbstractLauncherBackend): Controller instance.
|
||||
"""
|
||||
|
||||
_not_open_workfile_reg_key = "force_not_open_workfile"
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
|
|
@ -274,11 +100,21 @@ class ActionsModel:
|
|||
self._discovered_actions = None
|
||||
self._actions = None
|
||||
self._action_items = {}
|
||||
|
||||
self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool")
|
||||
self._webaction_items = NestedCacheItem(
|
||||
levels=2, default_factory=list, lifetime=20,
|
||||
)
|
||||
|
||||
self._addons_manager = None
|
||||
|
||||
self._variant = get_settings_variant()
|
||||
|
||||
@staticmethod
|
||||
def calculate_full_label(label: str, variant_label: Optional[str]) -> str:
|
||||
"""Calculate full label from label and variant_label."""
|
||||
if variant_label:
|
||||
return " ".join([label, variant_label])
|
||||
return label
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
|
|
@ -289,39 +125,12 @@ class ActionsModel:
|
|||
self._discovered_actions = None
|
||||
self._actions = None
|
||||
self._action_items = {}
|
||||
self._webaction_items.reset()
|
||||
|
||||
self._controller.emit_event("actions.refresh.started")
|
||||
self._get_action_objects()
|
||||
self._controller.emit_event("actions.refresh.finished")
|
||||
|
||||
def _should_start_last_workfile(
|
||||
self,
|
||||
project_name,
|
||||
task_id,
|
||||
identifier,
|
||||
host_name,
|
||||
not_open_workfile_actions
|
||||
):
|
||||
if identifier in not_open_workfile_actions:
|
||||
return not not_open_workfile_actions[identifier]
|
||||
|
||||
task_name = None
|
||||
task_type = None
|
||||
if task_id is not None:
|
||||
task_entity = self._controller.get_task_entity(
|
||||
project_name, task_id
|
||||
)
|
||||
task_name = task_entity["name"]
|
||||
task_type = task_entity["taskType"]
|
||||
|
||||
output = should_use_last_workfile_on_launch(
|
||||
project_name,
|
||||
host_name,
|
||||
task_name,
|
||||
task_type
|
||||
)
|
||||
return output
|
||||
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
"""Get actions for project.
|
||||
|
||||
|
|
@ -332,53 +141,31 @@ class ActionsModel:
|
|||
|
||||
Returns:
|
||||
list[ActionItem]: List of actions.
|
||||
|
||||
"""
|
||||
not_open_workfile_actions = self._get_no_last_workfile_for_context(
|
||||
project_name, folder_id, task_id)
|
||||
selection = self._prepare_selection(project_name, folder_id, task_id)
|
||||
output = []
|
||||
action_items = self._get_action_items(project_name)
|
||||
for identifier, action in self._get_action_objects().items():
|
||||
if not action.is_compatible(selection):
|
||||
continue
|
||||
if action.is_compatible(selection):
|
||||
output.append(action_items[identifier])
|
||||
output.extend(self._get_webactions(selection))
|
||||
|
||||
action_item = action_items[identifier]
|
||||
# Handling of 'force_not_open_workfile' for applications
|
||||
if action_item.is_application:
|
||||
action_item = action_item.copy()
|
||||
start_last_workfile = self._should_start_last_workfile(
|
||||
project_name,
|
||||
task_id,
|
||||
identifier,
|
||||
action.application.host_name,
|
||||
not_open_workfile_actions
|
||||
)
|
||||
action_item.force_not_open_workfile = (
|
||||
not start_last_workfile
|
||||
)
|
||||
|
||||
output.append(action_item)
|
||||
return output
|
||||
|
||||
def set_application_force_not_open_workfile(
|
||||
self, project_name, folder_id, task_id, action_ids, enabled
|
||||
def trigger_action(
|
||||
self,
|
||||
identifier,
|
||||
project_name,
|
||||
folder_id,
|
||||
task_id,
|
||||
):
|
||||
no_workfile_reg_data = self._get_no_last_workfile_reg_data()
|
||||
project_data = no_workfile_reg_data.setdefault(project_name, {})
|
||||
folder_data = project_data.setdefault(folder_id, {})
|
||||
task_data = folder_data.setdefault(task_id, {})
|
||||
for action_id in action_ids:
|
||||
task_data[action_id] = enabled
|
||||
self._launcher_tool_reg.set_item(
|
||||
self._not_open_workfile_reg_key, no_workfile_reg_data
|
||||
)
|
||||
|
||||
def trigger_action(self, project_name, folder_id, task_id, identifier):
|
||||
selection = self._prepare_selection(project_name, folder_id, task_id)
|
||||
failed = False
|
||||
error_message = None
|
||||
action_label = identifier
|
||||
action_items = self._get_action_items(project_name)
|
||||
trigger_id = uuid.uuid4().hex
|
||||
try:
|
||||
action = self._actions[identifier]
|
||||
action_item = action_items[identifier]
|
||||
|
|
@ -386,22 +173,11 @@ class ActionsModel:
|
|||
self._controller.emit_event(
|
||||
"action.trigger.started",
|
||||
{
|
||||
"trigger_id": trigger_id,
|
||||
"identifier": identifier,
|
||||
"full_label": action_label,
|
||||
}
|
||||
)
|
||||
if isinstance(action, ApplicationAction):
|
||||
per_action = self._get_no_last_workfile_for_context(
|
||||
project_name, folder_id, task_id
|
||||
)
|
||||
start_last_workfile = self._should_start_last_workfile(
|
||||
project_name,
|
||||
task_id,
|
||||
identifier,
|
||||
action.application.host_name,
|
||||
per_action
|
||||
)
|
||||
action.data["start_last_workfile"] = start_last_workfile
|
||||
|
||||
action.process(selection)
|
||||
except Exception as exc:
|
||||
|
|
@ -412,6 +188,7 @@ class ActionsModel:
|
|||
self._controller.emit_event(
|
||||
"action.trigger.finished",
|
||||
{
|
||||
"trigger_id": trigger_id,
|
||||
"identifier": identifier,
|
||||
"failed": failed,
|
||||
"error_message": error_message,
|
||||
|
|
@ -419,32 +196,148 @@ class ActionsModel:
|
|||
}
|
||||
)
|
||||
|
||||
def trigger_webaction(self, context, action_label, form_data):
|
||||
entity_type = None
|
||||
entity_ids = []
|
||||
identifier = context.identifier
|
||||
folder_id = context.folder_id
|
||||
task_id = context.task_id
|
||||
project_name = context.project_name
|
||||
addon_name = context.addon_name
|
||||
addon_version = context.addon_version
|
||||
|
||||
if task_id:
|
||||
entity_type = "task"
|
||||
entity_ids.append(task_id)
|
||||
elif folder_id:
|
||||
entity_type = "folder"
|
||||
entity_ids.append(folder_id)
|
||||
|
||||
query = {
|
||||
"addonName": addon_name,
|
||||
"addonVersion": addon_version,
|
||||
"identifier": identifier,
|
||||
"variant": self._variant,
|
||||
}
|
||||
url = f"actions/execute?{urlencode(query)}"
|
||||
request_data = {
|
||||
"projectName": project_name,
|
||||
"entityType": entity_type,
|
||||
"entityIds": entity_ids,
|
||||
}
|
||||
if form_data is not None:
|
||||
request_data["formData"] = form_data
|
||||
|
||||
trigger_id = uuid.uuid4().hex
|
||||
failed = False
|
||||
try:
|
||||
self._controller.emit_event(
|
||||
"webaction.trigger.started",
|
||||
{
|
||||
"trigger_id": trigger_id,
|
||||
"identifier": identifier,
|
||||
"full_label": action_label,
|
||||
}
|
||||
)
|
||||
|
||||
conn = ayon_api.get_server_api_connection()
|
||||
# Add 'referer' header to the request
|
||||
# - ayon-api 1.1.1 adds the value to the header automatically
|
||||
headers = conn.get_headers()
|
||||
if "referer" in headers:
|
||||
headers = None
|
||||
else:
|
||||
headers["referer"] = conn.get_base_url()
|
||||
response = ayon_api.raw_post(
|
||||
url, headers=headers, json=request_data
|
||||
)
|
||||
response.raise_for_status()
|
||||
handle_response = self._handle_webaction_response(response.data)
|
||||
|
||||
except Exception:
|
||||
failed = True
|
||||
self.log.warning("Action trigger failed.", exc_info=True)
|
||||
handle_response = WebactionResponse(
|
||||
"unknown",
|
||||
False,
|
||||
error_message="Failed to trigger webaction.",
|
||||
)
|
||||
|
||||
data = asdict(handle_response)
|
||||
data.update({
|
||||
"trigger_failed": failed,
|
||||
"trigger_id": trigger_id,
|
||||
"identifier": identifier,
|
||||
"full_label": action_label,
|
||||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
"task_id": task_id,
|
||||
"addon_name": addon_name,
|
||||
"addon_version": addon_version,
|
||||
})
|
||||
self._controller.emit_event(
|
||||
"webaction.trigger.finished",
|
||||
data,
|
||||
)
|
||||
|
||||
def get_action_config_values(self, context: WebactionContext):
|
||||
selection = self._prepare_selection(
|
||||
context.project_name, context.folder_id, context.task_id
|
||||
)
|
||||
if not selection.is_project_selected:
|
||||
return {}
|
||||
|
||||
request_data = self._get_webaction_request_data(selection)
|
||||
|
||||
query = {
|
||||
"addonName": context.addon_name,
|
||||
"addonVersion": context.addon_version,
|
||||
"identifier": context.identifier,
|
||||
"variant": self._variant,
|
||||
}
|
||||
url = f"actions/config?{urlencode(query)}"
|
||||
try:
|
||||
response = ayon_api.post(url, **request_data)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"Failed to collect webaction config values.",
|
||||
exc_info=True
|
||||
)
|
||||
return {}
|
||||
return response.data
|
||||
|
||||
def set_action_config_values(self, context, values):
|
||||
selection = self._prepare_selection(
|
||||
context.project_name, context.folder_id, context.task_id
|
||||
)
|
||||
if not selection.is_project_selected:
|
||||
return {}
|
||||
|
||||
request_data = self._get_webaction_request_data(selection)
|
||||
request_data["value"] = values
|
||||
|
||||
query = {
|
||||
"addonName": context.addon_name,
|
||||
"addonVersion": context.addon_version,
|
||||
"identifier": context.identifier,
|
||||
"variant": self._variant,
|
||||
}
|
||||
url = f"actions/config?{urlencode(query)}"
|
||||
try:
|
||||
response = ayon_api.post(url, **request_data)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"Failed to store webaction config values.",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
def _get_addons_manager(self):
|
||||
if self._addons_manager is None:
|
||||
self._addons_manager = AddonsManager()
|
||||
return self._addons_manager
|
||||
|
||||
def _get_no_last_workfile_reg_data(self):
|
||||
try:
|
||||
no_workfile_reg_data = self._launcher_tool_reg.get_item(
|
||||
self._not_open_workfile_reg_key)
|
||||
except ValueError:
|
||||
no_workfile_reg_data = {}
|
||||
self._launcher_tool_reg.set_item(
|
||||
self._not_open_workfile_reg_key, no_workfile_reg_data)
|
||||
return no_workfile_reg_data
|
||||
|
||||
def _get_no_last_workfile_for_context(
|
||||
self, project_name, folder_id, task_id
|
||||
):
|
||||
not_open_workfile_reg_data = self._get_no_last_workfile_reg_data()
|
||||
return (
|
||||
not_open_workfile_reg_data
|
||||
.get(project_name, {})
|
||||
.get(folder_id, {})
|
||||
.get(task_id, {})
|
||||
)
|
||||
|
||||
def _prepare_selection(self, project_name, folder_id, task_id):
|
||||
project_entity = None
|
||||
if project_name:
|
||||
|
|
@ -458,6 +351,179 @@ class ActionsModel:
|
|||
project_settings=project_settings,
|
||||
)
|
||||
|
||||
def _get_webaction_request_data(self, selection: LauncherActionSelection):
|
||||
if not selection.is_project_selected:
|
||||
return None
|
||||
|
||||
entity_type = None
|
||||
entity_id = None
|
||||
entity_subtypes = []
|
||||
if selection.is_task_selected:
|
||||
entity_type = "task"
|
||||
entity_id = selection.task_entity["id"]
|
||||
entity_subtypes = [selection.task_entity["taskType"]]
|
||||
|
||||
elif selection.is_folder_selected:
|
||||
entity_type = "folder"
|
||||
entity_id = selection.folder_entity["id"]
|
||||
entity_subtypes = [selection.folder_entity["folderType"]]
|
||||
|
||||
entity_ids = []
|
||||
if entity_id:
|
||||
entity_ids.append(entity_id)
|
||||
|
||||
project_name = selection.project_name
|
||||
return {
|
||||
"projectName": project_name,
|
||||
"entityType": entity_type,
|
||||
"entitySubtypes": entity_subtypes,
|
||||
"entityIds": entity_ids,
|
||||
}
|
||||
|
||||
def _get_webactions(self, selection: LauncherActionSelection):
|
||||
if not selection.is_project_selected:
|
||||
return []
|
||||
|
||||
request_data = self._get_webaction_request_data(selection)
|
||||
project_name = selection.project_name
|
||||
entity_id = None
|
||||
if request_data["entityIds"]:
|
||||
entity_id = request_data["entityIds"][0]
|
||||
|
||||
cache: CacheItem = self._webaction_items[project_name][entity_id]
|
||||
if cache.is_valid:
|
||||
return cache.get_data()
|
||||
|
||||
try:
|
||||
response = ayon_api.post("actions/list", **request_data)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
self.log.warning("Failed to collect webactions.", exc_info=True)
|
||||
return []
|
||||
|
||||
action_items = []
|
||||
for action in response.data["actions"]:
|
||||
# NOTE Settings variant may be important for triggering?
|
||||
# - action["variant"]
|
||||
icon = action.get("icon")
|
||||
if icon and icon["type"] == "url":
|
||||
if not urlparse(icon["url"]).scheme:
|
||||
icon["type"] = "ayon_url"
|
||||
|
||||
config_fields = action.get("configFields") or []
|
||||
variant_label = action["label"]
|
||||
group_label = action.get("groupLabel")
|
||||
if not group_label:
|
||||
group_label = variant_label
|
||||
variant_label = None
|
||||
|
||||
full_label = self.calculate_full_label(
|
||||
group_label, variant_label
|
||||
)
|
||||
action_items.append(ActionItem(
|
||||
action_type="webaction",
|
||||
identifier=action["identifier"],
|
||||
order=action["order"],
|
||||
label=group_label,
|
||||
variant_label=variant_label,
|
||||
full_label=full_label,
|
||||
icon=icon,
|
||||
addon_name=action["addonName"],
|
||||
addon_version=action["addonVersion"],
|
||||
config_fields=config_fields,
|
||||
# category=action["category"],
|
||||
))
|
||||
|
||||
cache.update_data(action_items)
|
||||
return cache.get_data()
|
||||
|
||||
def _handle_webaction_response(self, data) -> WebactionResponse:
|
||||
response_type = data["type"]
|
||||
# Backwards compatibility -> 'server' type is not available since
|
||||
# AYON backend 1.8.3
|
||||
if response_type == "server":
|
||||
return WebactionResponse(
|
||||
response_type,
|
||||
False,
|
||||
error_message="Please use AYON web UI to run the action.",
|
||||
)
|
||||
|
||||
payload = data.get("payload") or {}
|
||||
|
||||
download_uri = payload.get("extra_download")
|
||||
if download_uri is not None:
|
||||
# Find out if is relative or absolute URL
|
||||
if not urlparse(download_uri).scheme:
|
||||
ayon_url = ayon_api.get_base_url().rstrip("/")
|
||||
path = download_uri.lstrip("/")
|
||||
download_uri = f"{ayon_url}/{path}"
|
||||
|
||||
# Use webbrowser to open file
|
||||
webbrowser.open_new_tab(download_uri)
|
||||
|
||||
response = WebactionResponse(
|
||||
response_type,
|
||||
data["success"],
|
||||
data.get("message"),
|
||||
payload.get("extra_clipboard"),
|
||||
)
|
||||
if response_type == "simple":
|
||||
pass
|
||||
|
||||
elif response_type == "redirect":
|
||||
# NOTE unused 'newTab' key because we always have to
|
||||
# open new tab from desktop app.
|
||||
if not webbrowser.open_new_tab(payload["uri"]):
|
||||
payload.error_message = "Failed to open web browser."
|
||||
|
||||
elif response_type == "form":
|
||||
submit_icon = payload["submit_icon"] or None
|
||||
cancel_icon = payload["cancel_icon"] or None
|
||||
if submit_icon:
|
||||
submit_icon = {
|
||||
"type": "material-symbols",
|
||||
"name": submit_icon,
|
||||
}
|
||||
|
||||
if cancel_icon:
|
||||
cancel_icon = {
|
||||
"type": "material-symbols",
|
||||
"name": cancel_icon,
|
||||
}
|
||||
|
||||
response.form = WebactionForm(
|
||||
fields=payload["fields"],
|
||||
title=payload["title"],
|
||||
submit_label=payload["submit_label"],
|
||||
cancel_label=payload["cancel_label"],
|
||||
submit_icon=submit_icon,
|
||||
cancel_icon=cancel_icon,
|
||||
)
|
||||
|
||||
elif response_type == "launcher":
|
||||
# Run AYON launcher process with uri in arguments
|
||||
# NOTE This does pass environment variables of current process
|
||||
# to the subprocess.
|
||||
# NOTE We could 'take action' directly and use the arguments here
|
||||
if payload is not None:
|
||||
uri = payload["uri"]
|
||||
else:
|
||||
uri = data["uri"]
|
||||
run_detached_ayon_launcher_process(uri)
|
||||
|
||||
elif response_type in ("query", "navigate"):
|
||||
response.error_message = (
|
||||
"Please use AYON web UI to run the action."
|
||||
)
|
||||
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Unknown webaction response type '{response_type}'"
|
||||
)
|
||||
response.error_message = "Unknown webaction response type."
|
||||
|
||||
return response
|
||||
|
||||
def _get_discovered_action_classes(self):
|
||||
if self._discovered_actions is None:
|
||||
# NOTE We don't need to register the paths, but that would
|
||||
|
|
@ -470,7 +536,6 @@ class ActionsModel:
|
|||
register_launcher_action_path(path)
|
||||
self._discovered_actions = (
|
||||
discover_launcher_actions()
|
||||
+ self._get_applications_action_classes()
|
||||
)
|
||||
return self._discovered_actions
|
||||
|
||||
|
|
@ -498,62 +563,29 @@ class ActionsModel:
|
|||
|
||||
action_items = {}
|
||||
for identifier, action in self._get_action_objects().items():
|
||||
is_application = isinstance(action, ApplicationAction)
|
||||
# Backwards compatibility from 0.3.3 (24/06/10)
|
||||
# TODO: Remove in future releases
|
||||
if is_application and hasattr(action, "project_settings"):
|
||||
if hasattr(action, "project_settings"):
|
||||
action.project_entities[project_name] = project_entity
|
||||
action.project_settings[project_name] = project_settings
|
||||
|
||||
label = action.label or identifier
|
||||
variant_label = getattr(action, "label_variant", None)
|
||||
full_label = self.calculate_full_label(
|
||||
label, variant_label
|
||||
)
|
||||
icon = get_action_icon(action)
|
||||
|
||||
item = ActionItem(
|
||||
identifier,
|
||||
label,
|
||||
variant_label,
|
||||
icon,
|
||||
action.order,
|
||||
is_application,
|
||||
False
|
||||
action_type="local",
|
||||
identifier=identifier,
|
||||
order=action.order,
|
||||
label=label,
|
||||
variant_label=variant_label,
|
||||
full_label=full_label,
|
||||
icon=icon,
|
||||
config_fields=[],
|
||||
)
|
||||
action_items[identifier] = item
|
||||
self._action_items[project_name] = action_items
|
||||
return action_items
|
||||
|
||||
def _get_applications_action_classes(self):
|
||||
addons_manager = self._get_addons_manager()
|
||||
applications_addon = addons_manager.get_enabled_addon("applications")
|
||||
if hasattr(applications_addon, "get_applications_action_classes"):
|
||||
return applications_addon.get_applications_action_classes()
|
||||
|
||||
# Backwards compatibility from 0.3.3 (24/06/10)
|
||||
# TODO: Remove in future releases
|
||||
actions = []
|
||||
if applications_addon is None:
|
||||
return actions
|
||||
|
||||
manager = applications_addon.get_applications_manager()
|
||||
for full_name, application in manager.applications.items():
|
||||
if not application.enabled:
|
||||
continue
|
||||
|
||||
action = type(
|
||||
"app_{}".format(full_name),
|
||||
(ApplicationAction,),
|
||||
{
|
||||
"identifier": "application.{}".format(full_name),
|
||||
"application": application,
|
||||
"name": application.name,
|
||||
"label": application.group.label,
|
||||
"label_variant": application.label,
|
||||
"group": None,
|
||||
"icon": application.icon,
|
||||
"color": getattr(application, "color", None),
|
||||
"order": getattr(application, "order", None) or 0,
|
||||
"data": {}
|
||||
}
|
||||
)
|
||||
actions.append(action)
|
||||
return actions
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
import os
|
||||
|
||||
RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_options_image_path():
|
||||
return os.path.join(RESOURCES_DIR, "options.png")
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,9 +1,9 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from ayon_core import style
|
||||
from ayon_core import resources
|
||||
from ayon_core import style, resources
|
||||
|
||||
from ayon_core.tools.launcher.control import BaseLauncherController
|
||||
from ayon_core.tools.utils import MessageOverlayObject
|
||||
|
||||
from .projects_widget import ProjectsWidget
|
||||
from .hierarchy_page import HierarchyPage
|
||||
|
|
@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
|
||||
self._controller = controller
|
||||
|
||||
overlay_object = MessageOverlayObject(self)
|
||||
|
||||
# Main content - Pages & Actions
|
||||
content_body = QtWidgets.QSplitter(self)
|
||||
|
||||
|
|
@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
content_body.setSizes([580, 160])
|
||||
|
||||
# Footer
|
||||
footer_widget = QtWidgets.QWidget(self)
|
||||
|
||||
# - Message label
|
||||
message_label = QtWidgets.QLabel(footer_widget)
|
||||
|
||||
# footer_widget = QtWidgets.QWidget(self)
|
||||
#
|
||||
# action_history = ActionHistory(footer_widget)
|
||||
# action_history.setStatusTip("Show Action History")
|
||||
|
||||
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
|
||||
footer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
footer_layout.addWidget(message_label, 1)
|
||||
#
|
||||
# footer_layout = QtWidgets.QHBoxLayout(footer_widget)
|
||||
# footer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# footer_layout.addWidget(action_history, 0)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addWidget(content_body, 1)
|
||||
layout.addWidget(footer_widget, 0)
|
||||
|
||||
message_timer = QtCore.QTimer()
|
||||
message_timer.setInterval(self.message_interval)
|
||||
message_timer.setSingleShot(True)
|
||||
# layout.addWidget(footer_widget, 0)
|
||||
|
||||
actions_refresh_timer = QtCore.QTimer()
|
||||
actions_refresh_timer.setInterval(self.refresh_interval)
|
||||
|
|
@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
|
||||
|
||||
projects_page.refreshed.connect(self._on_projects_refresh)
|
||||
message_timer.timeout.connect(self._on_message_timeout)
|
||||
actions_refresh_timer.timeout.connect(
|
||||
self._on_actions_refresh_timeout)
|
||||
page_slide_anim.valueChanged.connect(
|
||||
|
|
@ -128,6 +121,16 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
"action.trigger.finished",
|
||||
self._on_action_trigger_finished,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"webaction.trigger.started",
|
||||
self._on_webaction_trigger_started,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"webaction.trigger.finished",
|
||||
self._on_webaction_trigger_finished,
|
||||
)
|
||||
|
||||
self._overlay_object = overlay_object
|
||||
|
||||
self._controller = controller
|
||||
|
||||
|
|
@ -141,11 +144,8 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
self._projects_page = projects_page
|
||||
self._hierarchy_page = hierarchy_page
|
||||
self._actions_widget = actions_widget
|
||||
|
||||
self._message_label = message_label
|
||||
# self._action_history = action_history
|
||||
|
||||
self._message_timer = message_timer
|
||||
self._actions_refresh_timer = actions_refresh_timer
|
||||
self._page_slide_anim = page_slide_anim
|
||||
|
||||
|
|
@ -185,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
else:
|
||||
self._refresh_on_activate = True
|
||||
|
||||
def _echo(self, message):
|
||||
self._message_label.setText(str(message))
|
||||
self._message_timer.start()
|
||||
|
||||
def _on_message_timeout(self):
|
||||
self._message_label.setText("")
|
||||
|
||||
def _on_project_selection_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._selected_project_name = project_name
|
||||
|
|
@ -215,13 +208,69 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
self._hierarchy_page.refresh()
|
||||
self._actions_widget.refresh()
|
||||
|
||||
def _show_toast_message(self, message, success=True, message_id=None):
|
||||
message_type = None
|
||||
if not success:
|
||||
message_type = "error"
|
||||
|
||||
self._overlay_object.add_message(
|
||||
message, message_type, message_id=message_id
|
||||
)
|
||||
|
||||
def _on_action_trigger_started(self, event):
|
||||
self._echo("Running action: {}".format(event["full_label"]))
|
||||
self._show_toast_message(
|
||||
"Running: {}".format(event["full_label"]),
|
||||
message_id=event["trigger_id"],
|
||||
)
|
||||
|
||||
def _on_action_trigger_finished(self, event):
|
||||
if not event["failed"]:
|
||||
action_label = event["full_label"]
|
||||
if event["failed"]:
|
||||
message = f"Failed to run: {action_label}"
|
||||
else:
|
||||
message = f"Finished: {action_label}"
|
||||
self._show_toast_message(
|
||||
message,
|
||||
not event["failed"],
|
||||
message_id=event["trigger_id"],
|
||||
)
|
||||
|
||||
def _on_webaction_trigger_started(self, event):
|
||||
self._show_toast_message(
|
||||
"Running: {}".format(event["full_label"]),
|
||||
message_id=event["trigger_id"],
|
||||
)
|
||||
|
||||
def _on_webaction_trigger_finished(self, event):
|
||||
clipboard_text = event["clipboard_text"]
|
||||
if clipboard_text:
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
clipboard.setText(clipboard_text)
|
||||
|
||||
action_label = event["full_label"]
|
||||
# Avoid to show exception message
|
||||
if event["trigger_failed"]:
|
||||
self._show_toast_message(
|
||||
f"Failed to run: {action_label}",
|
||||
message_id=event["trigger_id"]
|
||||
)
|
||||
return
|
||||
self._echo("Failed: {}".format(event["error_message"]))
|
||||
|
||||
# Failed to run webaction, e.g. because of missing webaction handling
|
||||
# - not reported by server
|
||||
if event["error_message"]:
|
||||
self._show_toast_message(
|
||||
event["error_message"],
|
||||
success=False,
|
||||
message_id=event["trigger_id"]
|
||||
)
|
||||
return
|
||||
|
||||
if event["message"]:
|
||||
self._show_toast_message(event["message"], event["success"])
|
||||
|
||||
if event["form"]:
|
||||
self._actions_widget.handle_webaction_form_event(event)
|
||||
|
||||
def _is_page_slide_anim_running(self):
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -84,15 +84,17 @@ def _get_options(action, action_item, parent):
|
|||
if not getattr(action, "optioned", False) or not options:
|
||||
return {}
|
||||
|
||||
dialog_title = action.label + " Options"
|
||||
if isinstance(options[0], AbstractAttrDef):
|
||||
qargparse_options = False
|
||||
dialog = AttributeDefinitionsDialog(options, parent)
|
||||
dialog = AttributeDefinitionsDialog(
|
||||
options, title=dialog_title, parent=parent
|
||||
)
|
||||
else:
|
||||
qargparse_options = True
|
||||
dialog = OptionDialog(parent)
|
||||
dialog.create(options)
|
||||
|
||||
dialog.setWindowTitle(action.label + " Options")
|
||||
dialog.setWindowTitle(dialog_title)
|
||||
|
||||
if not dialog.exec_():
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
remove_container(container)
|
||||
self.data_changed.emit()
|
||||
|
||||
def _show_version_error_dialog(self, version, item_ids):
|
||||
def _show_version_error_dialog(self, version, item_ids, exception):
|
||||
"""Shows QMessageBox when version switch doesn't work
|
||||
|
||||
Args:
|
||||
version: str or int or None
|
||||
item_ids (Iterable[str]): List of item ids to run the
|
||||
exception (Exception): Exception that occurred
|
||||
"""
|
||||
if version == -1:
|
||||
version_str = "latest"
|
||||
|
|
@ -988,10 +990,11 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
dialog.addButton(QtWidgets.QMessageBox.Cancel)
|
||||
|
||||
msg = (
|
||||
"Version update to '{}' failed as representation doesn't exist."
|
||||
"Version update to '{}' failed with the following error:\n"
|
||||
"{}."
|
||||
"\n\nPlease update to version with a valid representation"
|
||||
" OR \n use 'Switch Folder' button to change folder."
|
||||
).format(version_str)
|
||||
).format(version_str, exception)
|
||||
dialog.setText(msg)
|
||||
dialog.exec_()
|
||||
|
||||
|
|
@ -1105,10 +1108,10 @@ class SceneInventoryView(QtWidgets.QTreeView):
|
|||
container = containers_by_id[item_id]
|
||||
try:
|
||||
update_container(container, item_version)
|
||||
except AssertionError:
|
||||
except Exception as exc:
|
||||
log.warning("Update failed", exc_info=True)
|
||||
self._show_version_error_dialog(
|
||||
item_version, [item_id]
|
||||
item_version, [item_id], exc
|
||||
)
|
||||
finally:
|
||||
# Always update the scene inventory view, even if errors occurred
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .widgets import (
|
|||
CustomTextComboBox,
|
||||
PlaceholderLineEdit,
|
||||
PlaceholderPlainTextEdit,
|
||||
MarkdownLabel,
|
||||
ElideLabel,
|
||||
HintedLineEdit,
|
||||
ExpandingTextEdit,
|
||||
|
|
@ -91,6 +92,7 @@ __all__ = (
|
|||
"CustomTextComboBox",
|
||||
"PlaceholderLineEdit",
|
||||
"PlaceholderPlainTextEdit",
|
||||
"MarkdownLabel",
|
||||
"ElideLabel",
|
||||
"HintedLineEdit",
|
||||
"ExpandingTextEdit",
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ except AttributeError:
|
|||
DEFAULT_PROJECT_LABEL = "< Default >"
|
||||
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101
|
||||
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102
|
||||
DEFAULT_WEB_ICON_COLOR = "#f4f5f5"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import os
|
||||
import sys
|
||||
import io
|
||||
import contextlib
|
||||
import collections
|
||||
import traceback
|
||||
import urllib.request
|
||||
from functools import partial
|
||||
from typing import Union, Any
|
||||
|
||||
import ayon_api
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
import qtawesome
|
||||
import qtmaterialsymbols
|
||||
|
|
@ -17,7 +20,12 @@ from ayon_core.style import (
|
|||
from ayon_core.resources import get_image_path
|
||||
from ayon_core.lib import Logger
|
||||
|
||||
from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT
|
||||
from .constants import (
|
||||
CHECKED_INT,
|
||||
UNCHECKED_INT,
|
||||
PARTIALLY_CHECKED_INT,
|
||||
DEFAULT_WEB_ICON_COLOR,
|
||||
)
|
||||
|
||||
log = Logger.get_logger(__name__)
|
||||
|
||||
|
|
@ -480,11 +488,27 @@ class _IconsCache:
|
|||
if icon_type == "path":
|
||||
parts = [icon_type, icon_def["path"]]
|
||||
|
||||
elif icon_type in {"awesome-font", "material-symbols"}:
|
||||
color = icon_def["color"] or ""
|
||||
elif icon_type == "awesome-font":
|
||||
color = icon_def.get("color") or ""
|
||||
if isinstance(color, QtGui.QColor):
|
||||
color = color.name()
|
||||
parts = [icon_type, icon_def["name"] or "", color]
|
||||
|
||||
elif icon_type == "material-symbols":
|
||||
color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
|
||||
if isinstance(color, QtGui.QColor):
|
||||
color = color.name()
|
||||
parts = [icon_type, icon_def["name"] or "", color]
|
||||
|
||||
elif icon_type in {"url", "ayon_url"}:
|
||||
parts = [icon_type, icon_def["url"]]
|
||||
|
||||
elif icon_type == "transparent":
|
||||
size = icon_def.get("size")
|
||||
if size is None:
|
||||
size = 256
|
||||
parts = [icon_type, str(size)]
|
||||
|
||||
return "|".join(parts)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -505,7 +529,7 @@ class _IconsCache:
|
|||
|
||||
elif icon_type == "awesome-font":
|
||||
icon_name = icon_def["name"]
|
||||
icon_color = icon_def["color"]
|
||||
icon_color = icon_def.get("color")
|
||||
icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color)
|
||||
if icon is None:
|
||||
icon = cls.get_qta_icon_by_name_and_color(
|
||||
|
|
@ -513,10 +537,40 @@ class _IconsCache:
|
|||
|
||||
elif icon_type == "material-symbols":
|
||||
icon_name = icon_def["name"]
|
||||
icon_color = icon_def["color"]
|
||||
icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
|
||||
if qtmaterialsymbols.get_icon_name_char(icon_name) is not None:
|
||||
icon = qtmaterialsymbols.get_icon(icon_name, icon_color)
|
||||
|
||||
elif icon_type == "url":
|
||||
url = icon_def["url"]
|
||||
try:
|
||||
content = urllib.request.urlopen(url).read()
|
||||
pix = QtGui.QPixmap()
|
||||
pix.loadFromData(content)
|
||||
icon = QtGui.QIcon(pix)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Failed to download image '%s'", url, exc_info=True
|
||||
)
|
||||
icon = None
|
||||
|
||||
elif icon_type == "ayon_url":
|
||||
url = icon_def["url"].lstrip("/")
|
||||
url = f"{ayon_api.get_base_url()}/{url}"
|
||||
stream = io.BytesIO()
|
||||
ayon_api.download_file_to_stream(url, stream)
|
||||
pix = QtGui.QPixmap()
|
||||
pix.loadFromData(stream.getvalue())
|
||||
icon = QtGui.QIcon(pix)
|
||||
|
||||
elif icon_type == "transparent":
|
||||
size = icon_def.get("size")
|
||||
if size is None:
|
||||
size = 256
|
||||
pix = QtGui.QPixmap(size, size)
|
||||
pix.fill(QtCore.Qt.transparent)
|
||||
icon = QtGui.QIcon(pix)
|
||||
|
||||
if icon is None:
|
||||
icon = cls.get_default()
|
||||
cls._cache[cache_key] = icon
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui
|
|||
import qargparse
|
||||
import qtawesome
|
||||
|
||||
try:
|
||||
import markdown
|
||||
except Exception:
|
||||
markdown = None
|
||||
|
||||
from ayon_core.style import (
|
||||
get_objected_colors,
|
||||
get_style_image_path,
|
||||
|
|
@ -131,6 +136,37 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit):
|
|||
viewport.setPalette(filter_palette)
|
||||
|
||||
|
||||
class MarkdownLabel(QtWidgets.QLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Enable word wrap by default
|
||||
self.setWordWrap(True)
|
||||
|
||||
text_format_available = hasattr(QtCore.Qt, "MarkdownText")
|
||||
if text_format_available:
|
||||
self.setTextFormat(QtCore.Qt.MarkdownText)
|
||||
|
||||
self._text_format_available = text_format_available
|
||||
|
||||
self.setText(self.text())
|
||||
|
||||
def setText(self, text):
|
||||
if not self._text_format_available:
|
||||
text = self._md_to_html(text)
|
||||
super().setText(text)
|
||||
|
||||
@staticmethod
|
||||
def _md_to_html(text):
|
||||
if markdown is None:
|
||||
# This does add style definition to the markdown which does not
|
||||
# feel natural in the UI (but still better than raw MD).
|
||||
doc = QtGui.QTextDocument()
|
||||
doc.setMarkdown(text)
|
||||
return doc.toHtml()
|
||||
return markdown.markdown(text)
|
||||
|
||||
|
||||
class ElideLabel(QtWidgets.QLabel):
|
||||
"""Label which elide text.
|
||||
|
||||
|
|
@ -459,15 +495,15 @@ class ClickableLabel(QtWidgets.QLabel):
|
|||
"""Label that catch left mouse click and can trigger 'clicked' signal."""
|
||||
clicked = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent):
|
||||
super(ClickableLabel, self).__init__(parent)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._mouse_pressed = False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
self._mouse_pressed = True
|
||||
super(ClickableLabel, self).mousePressEvent(event)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._mouse_pressed:
|
||||
|
|
@ -475,7 +511,7 @@ class ClickableLabel(QtWidgets.QLabel):
|
|||
if self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
|
||||
super(ClickableLabel, self).mouseReleaseEvent(event)
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class ExpandBtnLabel(QtWidgets.QLabel):
|
||||
|
|
@ -704,7 +740,7 @@ class PixmapLabel(QtWidgets.QLabel):
|
|||
|
||||
def resizeEvent(self, event):
|
||||
self._set_resized_pix()
|
||||
super(PixmapLabel, self).resizeEvent(event)
|
||||
super().resizeEvent(event)
|
||||
|
||||
|
||||
class PixmapButtonPainter(QtWidgets.QWidget):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ description="AYON core addon."
|
|||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9.1,<3.10"
|
||||
markdown = "^3.4.1"
|
||||
clique = "1.6.*"
|
||||
jsonschema = "^2.6.0"
|
||||
pyblish-base = "^1.8.11"
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ client_dir = "ayon_core"
|
|||
|
||||
plugin_for = ["ayon_server"]
|
||||
|
||||
ayon_server_version = ">=1.7.6,<2.0.0"
|
||||
ayon_server_version = ">=1.8.4,<2.0.0"
|
||||
ayon_launcher_version = ">=1.0.2"
|
||||
ayon_required_addons = {}
|
||||
ayon_compatible_addons = {
|
||||
"ayon_ocio": ">=1.2.1",
|
||||
"applications": ">=1.1.2",
|
||||
"harmony": ">0.4.0",
|
||||
"fusion": ">=0.3.3",
|
||||
"openrv": ">=1.0.2",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue